<?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>Change-Streams on Tarragon</title><link>https://tarrragon.github.io/blog/tags/change-streams/</link><description>Recent content in Change-Streams on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 02 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/change-streams/index.xml" rel="self" type="application/rss+xml"/><item><title>Spanner Change Streams (CDC)：捕捉資料變更、watch partition、下游整合與 DynamoDB Streams 對照</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/change-streams-cdc/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/change-streams-cdc/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner&lt;/a> overview 的 implementation-layer deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 &lt;em>Change Streams&lt;/em> — Spanner 把 commit 後的 row mutation 變成下游可消費事件流的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">CDC&lt;/a> 機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題情境oltp-的變更要餵給搜尋快取分析三個下游">問題情境：OLTP 的變更要餵給搜尋、快取、分析三個下游&lt;/h2>
&lt;p>Change Streams 的責任是把 Spanner 內已 commit 的 row mutation 變成有序、可重放、攜帶 commit timestamp 的事件流、讓搜尋索引、快取、分析倉儲三類下游不用反覆 full-table scan 就能跟上主資料庫。OLTP 主庫負責正確寫入、下游各自負責自己的 query shape、兩邊之間需要一條「只送變更、不送全表」的管線、這條管線就是 CDC 的職責。&lt;/p>
&lt;p>讀者徵兆通常從這幾個地方浮現：搜尋團隊每 5 分鐘跑一次 full scan 把 orders 重灌進 Elasticsearch、Spanner CPU 被掃表打到 70%；快取層靠 TTL 過期被動失效、使用者看到舊價格;分析團隊想做近即時 dashboard、卻只有每日 batch export。共同壓力是「主庫的變更沒有一條乾淨的出口」、每個下游各自發明輪子去 poll 主庫。&lt;/p>
&lt;p>真實壓力場景：全球電商把訂單寫進 Spanner multi-region instance、需要把每筆訂單狀態變更同時推給 (1) 搜尋索引更新庫存可售性、(2) Pub/Sub 通知履約系統、(3) BigQuery 做近即時營收儀表板。三個下游對延遲、順序、retention 的要求不同、但都需要從同一條變更流取得資料。&lt;/p>
&lt;p>Case anchor：&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale&lt;/a> 提供「全球大規模 OLTP 寫入」的壓力 anchor — Google Ads / Play 計費的寫入量級說明為什麼下游不能靠 full scan 跟上。&lt;strong>dogfood 邊界明示&lt;/strong>：9.C10 是 Google 內部 dogfood case、未展開 change streams 實作細節；本文 change stream 的物件模型、partition 行為與 retention 上限均來自 GCP vendor 規格、不是 9.C10 case 揭露。&lt;/p>
&lt;h2 id="核心機制data-change-recordpartition-tokencommit-timestamp">核心機制：data change record、partition token、commit timestamp&lt;/h2>
&lt;p>Change Stream 是一個用 DDL 建立、綁定到特定 table / column 集合的 schema 物件、commit 後 Spanner 把對應 row 的 mutation 寫成 &lt;em>data change record&lt;/em> 供消費。它跟「在 application 層自己寫 outbox table」最大的差異是：change record 由 Spanner 內部跟 transaction commit 綁定產生、攜帶該 mutation 的 commit timestamp、繼承 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external consistency&lt;/a> 的全序性質、不需要 application 額外保證原子性。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/spanner/" data-link-title="Google Cloud Spanner" data-link-desc="全球分散式 strong-consistency OLTP、TrueTime API、線性擴展到 10 億 req/sec">Cloud Spanner</a> overview 的 implementation-layer deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Overview 已說明 Spanner 在全球 OLTP 譜系的定位、本文聚焦 <em>Change Streams</em> — Spanner 把 commit 後的 row mutation 變成下游可消費事件流的 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">CDC</a> 機制。</p></blockquote>
<hr>
<h2 id="問題情境oltp-的變更要餵給搜尋快取分析三個下游">問題情境：OLTP 的變更要餵給搜尋、快取、分析三個下游</h2>
<p>Change Streams 的責任是把 Spanner 內已 commit 的 row mutation 變成有序、可重放、攜帶 commit timestamp 的事件流、讓搜尋索引、快取、分析倉儲三類下游不用反覆 full-table scan 就能跟上主資料庫。OLTP 主庫負責正確寫入、下游各自負責自己的 query shape、兩邊之間需要一條「只送變更、不送全表」的管線、這條管線就是 CDC 的職責。</p>
<p>讀者徵兆通常從這幾個地方浮現：搜尋團隊每 5 分鐘跑一次 full scan 把 orders 重灌進 Elasticsearch、Spanner CPU 被掃表打到 70%；快取層靠 TTL 過期被動失效、使用者看到舊價格;分析團隊想做近即時 dashboard、卻只有每日 batch export。共同壓力是「主庫的變更沒有一條乾淨的出口」、每個下游各自發明輪子去 poll 主庫。</p>
<p>真實壓力場景：全球電商把訂單寫進 Spanner multi-region instance、需要把每筆訂單狀態變更同時推給 (1) 搜尋索引更新庫存可售性、(2) Pub/Sub 通知履約系統、(3) BigQuery 做近即時營收儀表板。三個下游對延遲、順序、retention 的要求不同、但都需要從同一條變更流取得資料。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/spanner-planetary-scale-database-gcp/" data-link-title="9.C10 Cloud Spanner：每秒 10 億請求的全球一致性資料庫" data-link-desc="Google Cloud Spanner 內部峰值 10 億 req/sec、跨地區強一致 — 全球分散式 OLTP 容量參考">9.C10 Cloud Spanner planetary scale</a> 提供「全球大規模 OLTP 寫入」的壓力 anchor — Google Ads / Play 計費的寫入量級說明為什麼下游不能靠 full scan 跟上。<strong>dogfood 邊界明示</strong>：9.C10 是 Google 內部 dogfood case、未展開 change streams 實作細節；本文 change stream 的物件模型、partition 行為與 retention 上限均來自 GCP vendor 規格、不是 9.C10 case 揭露。</p>
<h2 id="核心機制data-change-recordpartition-tokencommit-timestamp">核心機制：data change record、partition token、commit timestamp</h2>
<p>Change Stream 是一個用 DDL 建立、綁定到特定 table / column 集合的 schema 物件、commit 後 Spanner 把對應 row 的 mutation 寫成 <em>data change record</em> 供消費。它跟「在 application 層自己寫 outbox table」最大的差異是：change record 由 Spanner 內部跟 transaction commit 綁定產生、攜帶該 mutation 的 commit timestamp、繼承 <a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external consistency</a> 的全序性質、不需要 application 額外保證原子性。</p>
<p>建立語法是 DDL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 監看整個資料庫
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">CHANGE</span><span class="w"> </span><span class="n">STREAM</span><span class="w"> </span><span class="n">everything_stream</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">ALL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 只監看特定 table 的特定欄位
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">CHANGE</span><span class="w"> </span><span class="n">STREAM</span><span class="w"> </span><span class="n">orders_stream</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="k">FOR</span><span class="w"> </span><span class="n">orders</span><span class="p">(</span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">total_amount</span><span class="p">),</span><span class="w"> </span><span class="n">inventory</span><span class="p">(</span><span class="n">available_qty</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="k">OPTIONS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="n">retention_period</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;7d&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="n">value_capture_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;NEW_AND_OLD_VALUES&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="p">);</span></span></span></code></pre></div><p><code>value_capture_type</code> 決定 record 攜帶多少資料、三個選項對下游的意義不同：</p>
<table>
  <thead>
      <tr>
          <th>value_capture_type</th>
          <th>record 攜帶內容</th>
          <th>適合下游</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>OLD_AND_NEW_VALUES</code></td>
          <td>變更前後完整 row</td>
          <td>需要 diff / 審計 / 反向補償的下游</td>
      </tr>
      <tr>
          <td><code>NEW_VALUES</code></td>
          <td>變更後的值 + key</td>
          <td>搜尋索引、快取 upsert（只要最新狀態）</td>
      </tr>
      <tr>
          <td><code>NEW_ROW</code></td>
          <td>變更後完整 row（含未改欄位）</td>
          <td>不想自己拼 row 的下游、犧牲 record 體積</td>
      </tr>
  </tbody>
</table>
<h3 id="data-change-record-的關鍵欄位">Data change record 的關鍵欄位</h3>
<p>每筆 data change record 攜帶 commit timestamp、record sequence、transaction tag、mod type（INSERT / UPDATE / DELETE）、以及 primary key 與依 capture type 決定的 value payload。下游靠 commit timestamp + record sequence 在同一個 transaction 內重建變更順序、跨 transaction 則靠 commit timestamp 的全序。這條順序保證是 Spanner CDC 跟「自己 poll updated_at column」的根本差異：poll updated_at 在 clock skew 下會漏序、change stream 的順序由 TrueTime 撐住。</p>
<h3 id="watch-partitionchange-stream-的-partition-模型">Watch partition：change stream 的 partition 模型</h3>
<p>Change stream 的讀取單位是 <em>partition</em>、不是整條流。Spanner 把 change stream 依底層 key range 切成多個 partition、每個 partition 用一個 <em>partition token</em> 標識、消費者對每個 token 各開一個 <code>read</code> 呼叫並行讀。當底層資料 split 或 merge（Spanner 自動 re-balance key range）、partition 會產生 <em>child partition</em> — 父 partition 的 record 讀到結束時回傳 child partition token、消費者要接著去讀 child token、才不會漏掉 split 後的變更。</p>
<p>這個 child partition 的接力機制是 change stream 消費的核心複雜度。手刻消費者必須維護一張 partition token 的 watermark 表、處理 parent 結束 → child 開始的交棒、保證每個 token 只被一個 worker 讀。多數團隊不該手刻這層、應走 Dataflow connector（下節）讓它代管 partition 生命週期。</p>
<blockquote>
<p><strong>Scope warning</strong>：本節 data change record 欄位、value_capture_type 選項、child partition 接力語意均屬 GCP Spanner change streams 規格、實作前 cross-verify <a href="https://cloud.google.com/spanner/docs/change-streams">Spanner change streams 官方文件</a>。retention_period、partition 切分行為隨版本演進、非 9.C10 case 揭露。</p></blockquote>
<h2 id="操作流程建立-change-stream-到-dataflow-下游">操作流程：建立 change stream 到 Dataflow 下游</h2>
<h3 id="step-1建立-change-stream-並驗證">Step 1：建立 change stream 並驗證</h3>
<p>用 DDL 建立 change stream 後、用 information schema 確認它存在、並用 metadata 查詢確認監看範圍正確。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">CHANGE</span><span class="w"> </span><span class="n">STREAM</span><span class="w"> </span><span class="n">orders_stream</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">FOR</span><span class="w"> </span><span class="n">orders</span><span class="p">,</span><span class="w"> </span><span class="n">inventory</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">OPTIONS</span><span class="w"> </span><span class="p">(</span><span class="n">retention_period</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;7d&#39;</span><span class="p">);</span></span></span></code></pre></div><p>驗證：查 <code>INFORMATION_SCHEMA.CHANGE_STREAMS</code> 確認 stream 已建立、查 <code>CHANGE_STREAM_TABLES</code> 確認監看的 table 集合符合預期。若監看範圍寫錯（漏了某 table）、下游會靜默漏掉那張表的變更、這是高代價的靜默失敗、必須在這步驗證。</p>
<h3 id="step-2選消費路徑--dataflow-connector-為預設">Step 2：選消費路徑 — Dataflow connector 為預設</h3>
<p>消費 change stream 有三條路徑、對應不同的下游能力與運維成本：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>partition 管理</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dataflow + Apache Beam SpannerIO connector</td>
          <td>connector 代管</td>
          <td>串到 BigQuery / GCS / Pub/Sub、需 exactly-once</td>
      </tr>
      <tr>
          <td>Pub/Sub via Dataflow template</td>
          <td>template 代管</td>
          <td>fan-out 給多個事件驅動下游</td>
      </tr>
      <tr>
          <td>直接用 client library 讀 partition</td>
          <td>自己維護 token watermark</td>
          <td>客製化邏輯、能承擔 partition 生命週期工程</td>
      </tr>
  </tbody>
</table>
<p>Dataflow connector 是預設路徑、因為它代管 partition token 的 split / merge 接力、提供 checkpoint 與 exactly-once 到下游 sink。</p>
<h3 id="step-3部署-dataflow-pipeline-並驗證-end-to-end">Step 3：部署 Dataflow pipeline 並驗證 end-to-end</h3>
<p>用官方 Spanner-to-BigQuery 或 Spanner-to-PubSub Dataflow template 部署。驗證 end-to-end：在 Spanner 寫一筆變更、量它多久出現在下游、確認 commit timestamp 在下游被保留、確認 INSERT / UPDATE / DELETE 三種 mod type 都被正確處理（DELETE 特別容易在下游被漏掉、要專門測）。</p>
<h3 id="step-4rollback-boundary">Step 4：rollback boundary</h3>
<p>Change stream 是可加可刪的 schema 物件、<code>DROP CHANGE STREAM orders_stream</code> 即停止捕捉、不影響主表寫入。rollback boundary 在「停掉 Dataflow pipeline + 標記下游資料為 stale」、不是「改主庫 schema」 — change stream 本身對 OLTP write path 的影響極小、刪除它不需要 cutover window。</p>
<h2 id="失敗模式retention-過期下游慢於-retentiondelete-漏處理">失敗模式：retention 過期、下游慢於 retention、DELETE 漏處理</h2>
<h3 id="retention-窗口過期導致-partition-不可讀">Retention 窗口過期導致 partition 不可讀</h3>
<p>change stream 的 record 只保留 retention_period（預設 1 天、上限數天、查官方文件確認當前上限）。若下游消費者停機超過 retention 窗口、過期 partition 的 record 被 GC、消費者重啟後讀到 partition token 已失效的錯誤、那段變更永久漏掉。徵兆是消費者重啟後報 partition not found、下游資料出現一段空洞。修法是 retention_period 設成大於「最壞情況下游停機 + 重啟趕上」的時間、並對 change stream 的 consumer lag 設告警、lag 接近 retention 一半就 page。</p>
<blockquote>
<p><strong>Scope warning</strong>：retention_period 的預設值與上限屬 GCP 規格、隨版本變動、cross-verify 官方文件。本段 lag 告警閾值（retention 一半）是通用工程估算、不是 9.C10 case 揭露的數字。</p></blockquote>
<h3 id="下游消費吞吐慢於主庫寫入速率">下游消費吞吐慢於主庫寫入速率</h3>
<p>主庫 write rate 持續高於下游消費速率、consumer lag 單調上升、最終撞 retention 窗口漏資料。這在全球大規模 OLTP 寫入下是真實壓力 — 對應 9.C10 揭露的 Google internal dogfood 寫入量級（<strong>dogfood 邊界</strong>：該量級是 Google 全使用者加總、不是單一 instance 配額）。修法是擴 Dataflow worker、確認 partition 數足夠讓消費並行、必要時把單一 change stream 依 table 拆成多條降低單條負載。判讀訊號是 Dataflow backlog metric 持續成長、不是偶發 spike。</p>
<h3 id="delete-變更在下游被漏處理">DELETE 變更在下游被漏處理</h3>
<p>下游 pipeline 只處理 INSERT / UPDATE 的 upsert、忘了處理 DELETE 的 tombstone、導致下游索引 / 快取殘留已刪除的資料。徵兆是搜尋結果出現主庫已不存在的項目、對帳發現下游 row count 高於主庫。修法是 pipeline 顯式 handle mod type = DELETE、依 capture type 決定能否拿到 old values 來反向補償；若用 <code>NEW_VALUES</code> capture、DELETE record 只攜帶 key、下游必須靠 key 刪除、不能假設拿得到完整 old row。</p>
<h3 id="把-change-stream-當可靠-message-queue-用">把 change stream 當可靠 message queue 用</h3>
<p>change stream 是 <em>變更捕捉</em>、不是 general-purpose message bus。團隊若把它當成「任意事件都塞進來」的 queue、會發現它只能攜帶 row mutation、不能攜帶 application 自定義事件、且 retention 比專用 message broker 短。<strong>Anti-recommendation（何時不用）</strong>：需要長期保留、任意 payload、複雜 routing 的事件流、用 Pub/Sub 或 Kafka 當 SSoT、change stream 只負責「資料庫變更」這一類來源；把 application 業務事件硬塞進 change stream 是把 CDC 機制誤用成 event bus。</p>
<h2 id="容量與觀測consumer-lag-是核心健康訊號">容量與觀測：consumer lag 是核心健康訊號</h2>
<p>Change stream 的容量壓力集中在「下游能不能跟上主庫寫入」、核心 metric 是 consumer lag 與 partition 並行度。</p>
<p>必看 metric：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Dataflow data freshness / system lag   → 下游落後主庫 commit 的時間
</span></span><span class="line"><span class="ln">2</span><span class="cl">Dataflow backlog bytes / elements      → 未消費的 record 積壓量
</span></span><span class="line"><span class="ln">3</span><span class="cl">Spanner change stream partition count  → 並行讀取單位、隨底層 split 變化
</span></span><span class="line"><span class="ln">4</span><span class="cl">Spanner CPU utilization                → change stream 讀取也消耗主 instance CPU</span></span></code></pre></div><p>Change stream 的讀取消耗主 instance 的 CPU 與 read capacity、不是免費旁路。容量規劃要把「change stream 消費」當成額外 read workload 算進 instance sizing、回 <a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型</a>。用 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 把 consumer lag 跟 Spanner CPU 配成 evidence pair：lag 上升且 CPU 飽和、是 instance 容量不足；lag 上升但 CPU 有餘、是 Dataflow worker 不足。</p>
<p>Alert 建議：</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Warn</th>
          <th>Page</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dataflow data freshness</td>
          <td>&gt; retention 的 1/4</td>
          <td>&gt; retention 的 1/2</td>
      </tr>
      <tr>
          <td>Dataflow backlog 成長趨勢</td>
          <td>持續成長 30 分鐘</td>
          <td>持續成長 2 小時</td>
      </tr>
      <tr>
          <td>Spanner CPU（含 stream 讀取）</td>
          <td>&gt; 65%</td>
          <td>&gt; 80%</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>Scope warning</strong>：上述閾值為通用工程估算、依各團隊 retention 設定與 SLA 調整、非 9.C10 case 揭露的 production 數字。</p></blockquote>
<h2 id="邊界與整合跟-dynamodb-streams-對照何時不用-change-streams">邊界與整合：跟 DynamoDB Streams 對照、何時不用 change streams</h2>
<h3 id="跟-dynamodb-streams-的對照">跟 DynamoDB Streams 的對照</h3>
<p>Change Streams 跟 DynamoDB Streams 都是 managed CDC、但 partition 模型、ordering 範圍、retention 的設計取捨不同、選型時這三軸最關鍵。</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>Spanner Change Streams</th>
          <th>DynamoDB Streams</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Ordering 範圍</td>
          <td>commit timestamp 全序（繼承 external consistency）</td>
          <td>每個 shard / partition key 內有序、跨 partition 無全序</td>
      </tr>
      <tr>
          <td>Partition 模型</td>
          <td>隨底層 key range split / merge、child partition 接力</td>
          <td>對應 DynamoDB partition、shard 隨 partition 變化</td>
      </tr>
      <tr>
          <td>Retention</td>
          <td>retention_period 可設（天級、查官方上限）</td>
          <td>固定 24 小時</td>
      </tr>
      <tr>
          <td>消費路徑</td>
          <td>Dataflow / Pub/Sub / client library</td>
          <td>Lambda trigger / Kinesis Adapter</td>
      </tr>
      <tr>
          <td>Payload 控制</td>
          <td>value_capture_type 三選</td>
          <td>StreamViewType 四選（KEYS_ONLY / NEW / OLD / BOTH）</td>
      </tr>
  </tbody>
</table>
<p>關鍵差異在 ordering：Spanner change stream 繼承 external consistency、跨 partition 的 record 可用 commit timestamp 排出全序;DynamoDB Streams 只保證單 partition key 內有序、跨 partition 重組需要下游自己處理。retention 上 DynamoDB Streams 固定 24 小時、Spanner 可設更長、對「下游可能長時間停機」的場景 Spanner 較有彈性。消費模型上 DynamoDB Streams 跟 Lambda 整合最順、Spanner 跟 Dataflow / BigQuery 生態整合最順。</p>
<blockquote>
<p><strong>Scope warning</strong>：DynamoDB Streams 24 小時 retention 與 StreamViewType 屬 AWS 規格、Spanner retention 上限屬 GCP 規格、兩者均隨版本演進、cross-verify 各自官方文件。</p></blockquote>
<h3 id="何時不用-change-streams">何時不用 change streams</h3>
<p>單純需要「下游讀到最新狀態、不在意中間每筆變更」、且主庫變更率低、定期 batch export 反而更簡單、不必引入 change stream + Dataflow 的運維成本。對延遲不敏感的分析、走 BigQuery federation 直接查 Spanner（見 sibling）比建 CDC 管線更省。Anti-recommendation 的判準是：若下游不需要「每一筆變更的順序」、只需要「定期最新快照」、CDC 是過度工程。</p>
<h3 id="sibling-deep-articles-路由">Sibling deep articles 路由</h3>
<ul>
<li><a href="../bigquery-federation/">bigquery-federation</a>：不想建 CDC 管線、直接 federated query 查 Spanner 的 OLAP 路徑、跟 change stream → BigQuery 是兩條互補的整合方式</li>
<li><a href="../truetime-api-depth/">truetime-api-depth</a>：change stream 的 commit timestamp 全序來自 TrueTime、理解順序保證的物理基礎</li>
<li><a href="../consistency-models-comparison/">consistency-models-comparison</a>：change stream 繼承 external consistency、跟 DynamoDB Streams 的 per-partition ordering 對照回 linearizability 定義</li>
</ul>
<h3 id="跟-knowledge-card-的互引">跟 knowledge card 的互引</h3>
<ul>
<li><a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a> — 本文是這張卡的 Spanner 實作範例</li>
<li><a href="/blog/backend/knowledge-cards/external-consistency/" data-link-title="External Consistency" data-link-desc="交易可見順序與外部真實時間順序一致的強一致性語意">external-consistency</a> — change stream 的全序保證來源</li>
</ul>
<h3 id="跟-04--09-章節的互引">跟 04 / 09 章節的互引</h3>
<ul>
<li><a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>：consumer lag × Spanner CPU 的 evidence pair</li>
<li><a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型</a>：change stream 讀取當額外 read workload 算進 sizing</li>
</ul>
]]></content:encoded></item><item><title>MongoDB Change Streams + Kafka 整合：resume token、scope 選擇與 connector 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</guid><description>&lt;p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>MongoDB 適用度前置判讀&lt;/strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>、本篇不重複展開。Change streams 是 &lt;em>已選 MongoDB 後&lt;/em> 的 event-driven 整合議題。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷&lt;/h2>
&lt;p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：&lt;/p>
&lt;ul>
&lt;li>Downstream 漏 event 或 duplicate event&lt;/li>
&lt;li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌&lt;/li>
&lt;li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event&lt;/li>
&lt;/ul>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>MongoDB Kafka Connector log &lt;code>ChangeStreamHistoryLost&lt;/code> 或 &lt;code>ResumeTokenChanged&lt;/code>&lt;/li>
&lt;li>Downstream Kafka topic event count vs source collection write count 不平&lt;/li>
&lt;li>Replication oplog 跟 change stream consumer 的 lag 同時升&lt;/li>
&lt;/ul>
&lt;p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration&lt;/a>（pipeline-level migration 經驗對照）。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>MongoDB 適用度前置判讀</strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>、本篇不重複展開。Change streams 是 <em>已選 MongoDB 後</em> 的 event-driven 整合議題。</p></blockquote>
<h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷</h2>
<p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：</p>
<ul>
<li>Downstream 漏 event 或 duplicate event</li>
<li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌</li>
<li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event</li>
</ul>
<p>讀者徵兆：</p>
<ul>
<li>MongoDB Kafka Connector log <code>ChangeStreamHistoryLost</code> 或 <code>ResumeTokenChanged</code></li>
<li>Downstream Kafka topic event count vs source collection write count 不平</li>
<li>Replication oplog 跟 change stream consumer 的 lag 同時升</li>
</ul>
<p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration</a>（pipeline-level migration 經驗對照）。</p>
<h2 id="核心機制">核心機制</h2>
<p>Change stream 是 MongoDB 3.6+ 原生 CDC、本質上是 oplog tail 包裝成 cursor API。可以從 collection / database / cluster 三個 scope 開：</p>
<ul>
<li><strong>Collection-level</strong>：監看單一 collection 的變更</li>
<li><strong>Database-level</strong>：監看整個 database 的所有 collection</li>
<li><strong>Cluster-wide</strong>：監看整個 cluster 的所有 database</li>
</ul>
<p>Oplog 是 capped collection、預設 size = disk 5% 或 50GB（取較小）。Resume token 對應 oplog entry 的 timestamp + UUID + documentKey。Token 必須對應仍在 oplog 內的 entry — oplog 滾掉就拿不到 token 對應的位置、<code>ChangeStreamHistoryLost</code>。</p>
<p><strong>Resume token 兩種用法</strong>：</p>
<ul>
<li><code>_id</code>：每個 event 都帶、application 自己存</li>
<li><code>startAfter</code> / <code>resumeAfter</code> parameter：重啟 cursor 時帶上</li>
</ul>
<p><strong><code>fullDocument: &quot;updateLookup&quot;</code></strong>：update event 預設只給 delta、加這個 option 會額外 query 一次 primary 拿完整 doc；高頻 update 下成本顯著（primary 負擔翻倍）。</p>
<p><strong>Pre-image / post-image（6.0+）</strong>：可以拿到 update 前的 doc 狀態、需 collection-level option <code>changeStreamPreAndPostImages: true</code>。</p>
<p><strong>Cluster-wide vs collection-level change stream</strong>：</p>
<ul>
<li>Cluster-wide 必須打 mongos、event ordering 是 global</li>
<li>Collection-level 可直接打單 shard、ordering 只在該 shard 內</li>
<li>Sharded cluster 上 cluster-wide stream 容易把 mongos 變單點瓶頸（所有 shard 的 event 都收斂到 mongos）</li>
</ul>
<p><strong>MongoDB Kafka Connector</strong>（Confluent / MongoDB 官方）：</p>
<ul>
<li>Source connector：把 change stream → Kafka topic</li>
<li>Sink connector：把 Kafka topic → MongoDB</li>
<li>At-least-once 語義、需 application 處理 idempotency</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a>、<a href="/blog/backend/knowledge-cards/replication-channel/" data-link-title="Replication Channel" data-link-desc="說明多來源複製中，每個來源對應的獨立複製通道如何成為隔離單位">replication-channel</a>、<a href="/blog/backend/knowledge-cards/replication-slot/" data-link-title="Replication Slot" data-link-desc="說明邏輯複製如何用 slot 追蹤消費進度，並對來源端造成保留壓力">replication-slot</a>（MongoDB 沒 slot、概念對照）。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：scope 決策樹</strong>。</p>
<table>
  <thead>
      <tr>
          <th>Scope</th>
          <th>適用條件</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Collection-level</td>
          <td>單一 collection 的下游 sink、ordering 需求單一</td>
          <td>多 collection 要多 connector</td>
      </tr>
      <tr>
          <td>Database-level</td>
          <td>多 collection 共享 sink、ordering 跨 collection</td>
          <td>filter cost 在 connector 端</td>
      </tr>
      <tr>
          <td>Cluster-wide</td>
          <td>整個 cluster 統一 audit / replay</td>
          <td>mongos 單點瓶頸風險、event 量大</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 2：oplog sizing</strong>。容量公式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">oplog size &gt;= peak write rate × max acceptable consumer downtime</span></span></code></pre></div><p>典型設 24-72 小時可恢復窗口。例：peak 5K WPS、想容忍 48 小時 connector down、oplog 至少 5K × 86400 × 2 ÷ docs_per_GB ≈ 看實際 doc size 決定。在 Atlas 上 oplog size 可直接調、自管 cluster 改 <code>replSetResizeOplog</code>。</p>
<p><strong>Step 3：Kafka Connector 配置</strong>。</p>





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





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