<?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>Cdc on Tarragon</title><link>https://tarrragon.github.io/blog/tags/cdc/</link><description>Recent content in Cdc 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/cdc/index.xml" rel="self" type="application/rss+xml"/><item><title>MySQL Binary Log + CDC：Maxwell / Debezium 是 binlog 第二消費者</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/binlog-cdc/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/binlog-cdc/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>CDC&lt;/em> — Maxwell / Debezium 怎麼讀 binlog 產生 event stream。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>MySQL CDC 的核心定位是 &lt;em>binlog consumer&lt;/em>。&lt;/p>
&lt;p>這個誤解來自跟 PostgreSQL CDC（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &amp;#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium&lt;/a>）混用名詞。PG 的 logical decoding 是 &lt;em>MySQL 沒有的能力&lt;/em> — PG 有 logical event（INSERT / UPDATE / DELETE 加上欄位 metadata）、輸出格式是 logical（人可讀、schema-aware）。MySQL 的 binlog 是 &lt;em>physical&lt;/em> — 紀錄的是 row 的 binary image、不帶 schema 資訊。&lt;/p>
&lt;p>Maxwell / Debezium 對 MySQL 是 &lt;em>binlog 第二消費者&lt;/em>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Primary MySQL → binlog
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├→ Replica 1（讀 binlog 同步）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ├→ Replica 2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> └→ Maxwell / Debezium（讀 binlog 解析、發 Kafka）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跟 replica 同一份 binlog stream，並非 separate logical decoding output。這個結構決定 CDC consumer 的設計：必須 &lt;em>自己處理 schema&lt;/em>（從 information_schema 拉、跟 binlog event 對齊）、必須 &lt;em>自己 track position&lt;/em>（binlog file + position 或 GTID）。&lt;/p>
&lt;h2 id="binlog-formatstatement--row--mixed">Binlog format：STATEMENT / ROW / MIXED&lt;/h2>
&lt;p>MySQL binlog 有 3 種 format、CDC 只能用 ROW：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Format&lt;/th>
 &lt;th>紀錄內容&lt;/th>
 &lt;th>CDC 可用？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>STATEMENT&lt;/td>
 &lt;td>原始 SQL statement&lt;/td>
 &lt;td>不可用（CDC 看不到實際改的 row）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ROW&lt;/td>
 &lt;td>每個改變的 row（before + after image）&lt;/td>
 &lt;td>CDC 標準&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MIXED&lt;/td>
 &lt;td>預設 STATEMENT、特殊情況用 ROW&lt;/td>
 &lt;td>不推薦（CDC 行為不一致）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>ROW 是 CDC 唯一選擇、production 強制：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>CDC</em> — Maxwell / Debezium 怎麼讀 binlog 產生 event stream。</p></blockquote>
<hr>
<p>MySQL CDC 的核心定位是 <em>binlog consumer</em>。</p>
<p>這個誤解來自跟 PostgreSQL CDC（<a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a>）混用名詞。PG 的 logical decoding 是 <em>MySQL 沒有的能力</em> — PG 有 logical event（INSERT / UPDATE / DELETE 加上欄位 metadata）、輸出格式是 logical（人可讀、schema-aware）。MySQL 的 binlog 是 <em>physical</em> — 紀錄的是 row 的 binary image、不帶 schema 資訊。</p>
<p>Maxwell / Debezium 對 MySQL 是 <em>binlog 第二消費者</em>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Primary MySQL → binlog
</span></span><span class="line"><span class="ln">2</span><span class="cl">              ├→ Replica 1（讀 binlog 同步）
</span></span><span class="line"><span class="ln">3</span><span class="cl">              ├→ Replica 2
</span></span><span class="line"><span class="ln">4</span><span class="cl">              └→ Maxwell / Debezium（讀 binlog 解析、發 Kafka）</span></span></code></pre></div><p>跟 replica 同一份 binlog stream，並非 separate logical decoding output。這個結構決定 CDC consumer 的設計：必須 <em>自己處理 schema</em>（從 information_schema 拉、跟 binlog event 對齊）、必須 <em>自己 track position</em>（binlog file + position 或 GTID）。</p>
<h2 id="binlog-formatstatement--row--mixed">Binlog format：STATEMENT / ROW / MIXED</h2>
<p>MySQL binlog 有 3 種 format、CDC 只能用 ROW：</p>
<table>
  <thead>
      <tr>
          <th>Format</th>
          <th>紀錄內容</th>
          <th>CDC 可用？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>STATEMENT</td>
          <td>原始 SQL statement</td>
          <td>不可用（CDC 看不到實際改的 row）</td>
      </tr>
      <tr>
          <td>ROW</td>
          <td>每個改變的 row（before + after image）</td>
          <td>CDC 標準</td>
      </tr>
      <tr>
          <td>MIXED</td>
          <td>預設 STATEMENT、特殊情況用 ROW</td>
          <td>不推薦（CDC 行為不一致）</td>
      </tr>
  </tbody>
</table>
<p>ROW 是 CDC 唯一選擇、production 強制：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">binlog_format</span> <span class="o">=</span> <span class="s">ROW</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">binlog_row_image</span> <span class="o">=</span> <span class="s">FULL  # FULL (all columns) / MINIMAL (only changed) / NOBLOB</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">log_bin_use_v1_row_events</span> <span class="o">=</span> <span class="s">0  # 用新版 event format</span></span></span></code></pre></div><p><code>binlog_row_image</code> 取捨：</p>
<ul>
<li><code>FULL</code>：每個 row event 包含所有 column（before + after）、binlog 大、CDC 完整</li>
<li><code>MINIMAL</code>：只包含 changed column + primary key、binlog 省 30-50% 空間、CDC 看不到 <em>未變 column</em></li>
<li><code>NOBLOB</code>：跟 FULL 一樣但 BLOB / TEXT column 只在 changed 時包含、平衡選擇</li>
</ul>
<p>對 <em>CDC 需要 full row payload</em>（例如下游 search index 重建）必須 <code>FULL</code>。對 <em>純 audit log</em> 可以 <code>MINIMAL</code>。</p>
<h2 id="row-format-的-raw-event-結構">ROW format 的 raw event 結構</h2>
<p>Binlog ROW event 的資料形狀是 <em>binary row image</em>，而非 <em>INSERT INTO orders VALUES (1, &lsquo;foo&rsquo;, 100)</em>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">TABLE_MAP_EVENT     - 對應 table schema metadata (table id + column type)
</span></span><span class="line"><span class="ln">2</span><span class="cl">                      ↓ 接續同一個 transaction 內所有 row event
</span></span><span class="line"><span class="ln">3</span><span class="cl">WRITE_ROWS_EVENT    - INSERT 的新 row image（column values）
</span></span><span class="line"><span class="ln">4</span><span class="cl">UPDATE_ROWS_EVENT   - UPDATE 的 before + after image
</span></span><span class="line"><span class="ln">5</span><span class="cl">DELETE_ROWS_EVENT   - DELETE 的 row image（被刪的 row）
</span></span><span class="line"><span class="ln">6</span><span class="cl">XID_EVENT           - transaction commit marker</span></span></code></pre></div><p>CDC consumer（Maxwell / Debezium）必須：</p>
<ol>
<li>接收 binlog event stream</li>
<li>看到 <code>TABLE_MAP_EVENT</code> 從中拿 table id → 對應 table name（cache 一份）</li>
<li>看到 <code>WRITE/UPDATE/DELETE_ROWS_EVENT</code> 用 table id 反查 schema、把 binary 解析成 column value</li>
<li>包成 JSON / Avro / Protobuf 推到 Kafka</li>
</ol>
<p>關鍵：<em>table schema 不在 binlog 內</em>、CDC consumer 必須 <em>獨立查 information_schema</em>。如果 schema 變了（ALTER TABLE）、CDC 必須 invalidate cache、重新查、否則新 column 的 row event 解析錯亂。</p>
<h2 id="maxwell-vs-debezium">Maxwell vs Debezium</h2>
<p>兩個是 MySQL CDC 主流選擇、不同設計取捨：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Maxwell</th>
          <th>Debezium MySQL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開發者</td>
          <td>Zendesk</td>
          <td>Red Hat</td>
      </tr>
      <tr>
          <td>語言</td>
          <td>Java（單一 binary）</td>
          <td>Java（Kafka Connect plugin）</td>
      </tr>
      <tr>
          <td>部署模式</td>
          <td>Standalone process</td>
          <td>Kafka Connect cluster</td>
      </tr>
      <tr>
          <td>支援 DB</td>
          <td>MySQL only</td>
          <td>MySQL / PostgreSQL / MongoDB / SQL Server / Oracle</td>
      </tr>
      <tr>
          <td>Output format</td>
          <td>JSON（內建）</td>
          <td>JSON / Avro / Protobuf（Kafka Connect）</td>
      </tr>
      <tr>
          <td>Producer</td>
          <td>Kafka / Kinesis / RabbitMQ / Pub/Sub</td>
          <td>Kafka（Kafka Connect 限制）</td>
      </tr>
      <tr>
          <td>Schema registry</td>
          <td>不支援</td>
          <td>支援（Confluent Schema Registry / Apicurio）</td>
      </tr>
      <tr>
          <td>Transformation</td>
          <td>filter / stream-level（內建）</td>
          <td>Single Message Transform (SMT)</td>
      </tr>
      <tr>
          <td>Bootstrapping</td>
          <td>一個 utility 從 <code>SELECT *</code> snapshot</td>
          <td>Built-in snapshot mode</td>
      </tr>
      <tr>
          <td>GTID 支援</td>
          <td>支援</td>
          <td>支援</td>
      </tr>
      <tr>
          <td>簡單性</td>
          <td>高（單一 binary）</td>
          <td>中（Kafka Connect 框架成本）</td>
      </tr>
  </tbody>
</table>
<p>選擇邏輯：</p>
<ul>
<li><em>只用 MySQL + 想要 simple operations</em> → Maxwell</li>
<li><em>已用 Kafka Connect、需要 schema registry、跨多種 DB</em> → Debezium</li>
<li><em>需要 Avro / Protobuf schema 嚴格 governance</em> → Debezium</li>
</ul>
<h2 id="配置-step-by-stepdebezium-mysql-connector">配置 step-by-step（Debezium MySQL connector）</h2>
<p>Debezium 是 Kafka Connect plugin、整套 stack：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># debezium-mysql.json - 部署到 Kafka Connect REST API</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span>{<span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">&#34;name&#34;: </span><span class="s2">&#34;orders-mysql-connector&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">&#34;config&#34;: </span>{<span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">&#34;connector.class&#34;: </span><span class="s2">&#34;io.debezium.connector.mysql.MySqlConnector&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.hostname&#34;: </span><span class="s2">&#34;primary.example.com&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.port&#34;: </span><span class="s2">&#34;3306&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.user&#34;: </span><span class="s2">&#34;debezium&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.password&#34;: </span><span class="s2">&#34;...&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.server.id&#34;: </span><span class="s2">&#34;184054&#34;</span><span class="p">,</span><span class="w">          </span><span class="c"># 唯一 server ID (跟 MySQL replica 一樣)</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">&#34;topic.prefix&#34;: </span><span class="s2">&#34;production&#34;</span><span class="p">,</span><span class="w">            </span><span class="c"># Debezium 2.x（舊 1.x 用 database.server.name）</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.include.list&#34;: </span><span class="s2">&#34;orders_db&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">&#34;table.include.list&#34;: </span><span class="s2">&#34;orders_db.orders,orders_db.payments&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.history.kafka.bootstrap.servers&#34;: </span><span class="s2">&#34;kafka:9092&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">&#34;database.history.kafka.topic&#34;: </span><span class="s2">&#34;dbhistory.orders&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="nt">&#34;include.schema.changes&#34;: </span><span class="s2">&#34;true&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">    </span><span class="nt">&#34;snapshot.mode&#34;: </span><span class="s2">&#34;initial&#34;</span><span class="p">,</span><span class="w">              </span><span class="c"># 或 schema_only / when_needed / never</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">    </span><span class="nt">&#34;snapshot.locking.mode&#34;: </span><span class="s2">&#34;minimal&#34;</span><span class="p">,</span><span class="w">      </span><span class="c"># 避免 FLUSH TABLES WITH READ LOCK</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">    </span><span class="nt">&#34;gtid.source.includes&#34;: </span><span class="s2">&#34;...&#34;</span><span class="p">,</span><span class="w">           </span><span class="c"># 可選 GTID filter</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">    </span><span class="nt">&#34;tombstones.on.delete&#34;: </span><span class="s2">&#34;true&#34;</span><span class="p">,</span><span class="w">          </span><span class="c"># DELETE event 同 partition 跟一個 null tombstone</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">    </span><span class="nt">&#34;decimal.handling.mode&#34;: </span><span class="s2">&#34;double&#34;</span><span class="w">        </span><span class="c"># DECIMAL 處理: precise / string / double</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">  </span>}<span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w"></span>}</span></span></code></pre></div><p>deploy：</p>





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Debezium PostgreSQL connector</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">connector.class</span><span class="o">=</span><span class="s">io.debezium.connector.postgresql.PostgresConnector</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">database.hostname</span><span class="o">=</span><span class="s">primary</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">database.dbname</span><span class="o">=</span><span class="s">app</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">plugin.name</span><span class="o">=</span><span class="s">pgoutput                            # 內建、PG 10+ 推薦</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">slot.name</span><span class="o">=</span><span class="s">debezium_app</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">publication.name</span><span class="o">=</span><span class="s">app_changes</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">publication.autocreate.mode</span><span class="o">=</span><span class="s">filtered            # debezium 自動建 publication</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="na">table.include.list</span><span class="o">=</span><span class="s">public.orders,public.events</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">snapshot.mode</span><span class="o">=</span><span class="s">initial                            # 起始 snapshot 後 streaming</span></span></span></code></pre></div><p>差異：</p>
<ul>
<li>Debezium 用 <code>pgoutput</code>（PG 10+ 內建）或 <code>wal2json</code>（外掛 plugin）解 WAL、轉成結構化事件送 Kafka</li>
<li>不像 PG-to-PG subscription、Debezium 沒 subscription object、是 <em>外部 consumer 自管</em> replication slot</li>
<li>Failure mode 上 <em>consumer 端是 Debezium 自己</em>、所以 lag 來源是 Debezium 處理速度 / Kafka 寫入速度</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1consumer-lagslot-lsn-不前進primary-disk-爆">Case 1：consumer lag、slot LSN 不前進、primary disk 爆</h3>
<p><strong>徵兆</strong>：primary <code>pg_wal</code> 目錄持續長大、<code>df -h</code> 看磁碟 90%+；<code>pg_replication_slots</code> 看 <code>confirmed_flush_lsn</code> 卡在某 LSN、<code>pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)</code> 數十 GB。</p>
<p><strong>根因</strong>：consumer（Debezium / subscriber）處理慢於 primary 寫入；replication slot <em>保證 WAL 不回收</em>、但 consumer 沒消費 → WAL 堆積。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>監測</strong>：Prometheus alert <code>pg_replication_slot_lag_bytes &gt; 5GB</code> 觸發前 catch</li>
<li><strong>修 consumer</strong>：throttle primary 寫入 OR scale Debezium / subscriber 處理能力</li>
<li><strong>緊急</strong>：<code>SELECT pg_drop_replication_slot('debezium_app')</code> 釋放 WAL — 但 consumer 必須重 init load（資料缺一塊）</li>
<li><strong>架構</strong>：用 <em>max_slot_wal_keep_size</em>（PG 13+）設 slot 能保留 WAL 上限、超出自動 invalidate slot、保護 primary disk</li>
</ol>
<h3 id="case-2consumer-crash-後-slot-變-zombie">Case 2：consumer crash 後 slot 變 zombie</h3>
<p><strong>徵兆</strong>：Debezium pod OOM crash、新 pod 起來時報 <code>slot is active for PID X</code>、無法 attach；primary 端 <code>pg_replication_slots.active = true</code>、<code>active_pid</code> 指向已經死掉的 process。</p>
<p><strong>根因</strong>：PostgreSQL 把 slot 標 active 是基於 <em>當下有 connection</em>；consumer crash 但 connection 沒被 server 端發現（network 沒 RST）、slot 留在 active state。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 手動清 zombie slot
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_terminate_backend</span><span class="p">(</span><span class="n">active_pid</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">slot_name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;debezium_app&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">active</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 或直接 drop（會丟資料、consumer 要重 init）
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_drop_replication_slot</span><span class="p">(</span><span class="s1">&#39;debezium_app&#39;</span><span class="p">);</span></span></span></code></pre></div><p>預防：</p>
<ol>
<li>PostgreSQL <code>tcp_keepalives_idle / interval / count</code> 設較短（300 / 60 / 6）、network drop 較快被發現</li>
<li>Consumer 端用 <em>graceful shutdown</em> + <code>pg_terminate_backend(active_pid)</code> 在 startup 前主動清 stale connection</li>
</ol>
<h3 id="case-3schema-changedrop--rename-column斷流">Case 3：schema change（DROP / RENAME COLUMN）斷流</h3>
<p><strong>徵兆</strong>：Debezium consumer 突然停 produce 訊息、log 報 <code>column XYZ does not exist</code>；primary 端 slot 還 active、但 <code>confirmed_flush_lsn</code> 不前進。</p>
<p><strong>根因</strong>：pgoutput plugin 把 WAL 解成 row event 時、用的 schema 是 <em>當下 catalog</em>；如果中間 DROP COLUMN、之前 WAL 內的 row event 含已不存在欄位、解析失敗。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：schema change 走 <em>expand-contract pattern</em>
<ul>
<li>Phase 1: ADD COLUMN new_col（不影響 logical replication）</li>
<li>Phase 2: application 雙寫 old + new</li>
<li>Phase 3: 等 consumer catch up old column 訊息</li>
<li>Phase 4: DROP COLUMN old_col（此時無 in-flight WAL 帶 old_col）</li>
</ul>
</li>
<li><strong>緊急</strong>：DROP existing slot、重建 publication 跟 slot、consumer 重 init load</li>
<li><strong>長期</strong>：用 Debezium <em>snapshot.mode=schema_only_recovery</em> 在 schema 變動時不重灌資料、只 reset schema</li>
</ol>
<h3 id="case-4initial-copy-大表鎖太久">Case 4：initial COPY 大表鎖太久</h3>
<p><strong>徵兆</strong>：對 1TB 表跑 <code>CREATE SUBSCRIPTION ... WITH (copy_data=true)</code> 後、application 對該表 query / write 阻塞 30+ 分鐘；application timeout 大量。</p>
<p><strong>根因</strong>：initial COPY 默認跑在 <em>single transaction</em>、整個 snapshot LSN 鎖住、長 transaction 跟 vacuum 衝突；同時對 subscriber 端鎖表寫入。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>分階段 init</strong>：</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- Primary：建 publication 不 copy
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">PUBLICATION</span><span class="w"> </span><span class="n">app_changes</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">big_table</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- Subscriber：建 subscription 不 copy
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="n">SUBSCRIPTION</span><span class="w"> </span><span class="n">app_sub</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="k">CONNECTION</span><span class="w"> </span><span class="s1">&#39;...&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="n">PUBLICATION</span><span class="w"> </span><span class="n">app_changes</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">copy_data</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 手動跑 partition-by-partition COPY（若是 partition table）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- 或用 pg_dump / pg_basebackup 拿 snapshot</span></span></span></code></pre></div><ol start="2">
<li><strong>PG 16+ parallel init</strong>：<code>max_sync_workers_per_subscription = 4</code> 平行 COPY 多個表</li>
<li><strong>Debezium replacement</strong>：用 incremental snapshot（Debezium 1.6+）、background trickle copy、不鎖長 transaction</li>
</ol>
<h3 id="case-5replay-storm-後-consumer-offset-reset">Case 5：replay storm 後 consumer offset reset</h3>
<p><strong>徵兆</strong>：Debezium 修 bug / 重 deploy 後、<code>snapshot.mode=initial</code> 觸發整個資料重灌；Kafka topic 流量爆 10x、下游 application 看到大量 duplicate event。</p>
<p><strong>根因</strong>：Debezium offset store（Kafka topic 或 file）被誤刪 / corruption；重啟時不知道從哪 LSN 開始、預設 fall back 到 initial snapshot。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：Debezium offset store 跟 Kafka cluster <em>backup 一起做</em>、不要單獨依賴 Kafka topic</li>
<li><strong>架構</strong>：consumer side 設計 <em>idempotent</em> — 用 event 自帶的 (source LSN + transaction ID) 當 dedupe key</li>
<li><strong>transactional outbox pattern</strong>：CDC 只 capture outbox 表、application 主動寫 outbox + business data 在同 transaction；duplicate 由 application 自己 dedupe</li>
</ol>
<h2 id="容量規劃">容量規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Replication slot lag</td>
          <td><code>pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)</code></td>
          <td>&gt; 1GB lag 訊號 consumer 跟不上</td>
      </tr>
      <tr>
          <td>Primary <code>pg_wal</code> size</td>
          <td>retention × peak WAL rate</td>
          <td>預留 disk 容量 = max_slot_wal_keep_size + 30% buffer</td>
      </tr>
      <tr>
          <td>Debezium throughput</td>
          <td>~5-10K row/s 單 connector、多表平行可拉</td>
          <td>跟 primary write rate 對比</td>
      </tr>
      <tr>
          <td>Initial COPY time</td>
          <td>100GB ~ 10-30 分鐘（看 network + subscriber IO）</td>
          <td>TB 級必須分階段</td>
      </tr>
      <tr>
          <td>Slot 數量</td>
          <td>每 slot 佔 primary 一份 WAL 保留 buffer</td>
          <td>5+ slot 同時跑 disk 壓力倍增</td>
      </tr>
      <tr>
          <td>max_replication_slots</td>
          <td>預設 10、production 跑 CDC + standby 各佔 slot 要拉到 20-50</td>
          <td>達上限會拒新 slot 建立</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>Debezium production：1 connector per source schema、不要 1 connector 跨 50 個表</li>
<li>Slot retention：<code>max_slot_wal_keep_size = 100GB</code>、超出 invalidate slot 保護 primary</li>
<li>Monitor cadence：1 分鐘 sample lag + 5 分鐘 alert threshold</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-patroni-ha-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> 整合</h3>
<p>logical slot 在 PG 16- 不跨 failover、是長期痛點：</p>
<ol>
<li><strong>PG 16-</strong>：failover 後 logical consumer 必須重 init（slot 在新 leader 上不存在）</li>
<li><strong>PG 16+</strong>：<code>failover</code> parameter 讓 logical slot 在 standby 同步、failover 後 consumer 直接接</li>
<li>Patroni 16+ 支援 logical slot persistence 配置、配合用</li>
</ol>
<h3 id="跟-kafka-outbox-pattern">跟 Kafka outbox pattern</h3>
<p>production-grade CDC 不直接 read business table、是 read <em>outbox table</em>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- Application transaction
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">BEGIN</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="p">(...)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(...);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">outbox</span><span class="w"> </span><span class="p">(</span><span class="n">event_type</span><span class="p">,</span><span class="w"> </span><span class="n">payload</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;order_created&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;...&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">now</span><span class="p">());</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">COMMIT</span><span class="p">;</span></span></span></code></pre></div><p>Debezium 只 capture outbox table、event payload 已是 application-shaped JSON、不用解 row event。好處：</p>
<ol>
<li>Schema change 不影響 CDC（outbox table schema 穩定）</li>
<li>跨表 transaction 對應到單 event（outbox 是業務語意層）</li>
<li>Replay 可靠 — outbox 是 append-only、可重讀</li>
</ol>
<h3 id="跟-partitioning-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/declarative-partitioning/" data-link-title="PostgreSQL declarative partitioning：partition 不是切表、是讓 planner pruning" data-link-desc="Declarative partitioning 的真實價值是 query planner pruning &#43; maintenance scope 縮小、不是「把大表切小」；RANGE / LIST / HASH 取捨、partition key 選法、5 個 production 踩雷（key 選錯不 prune / unique 不 enforce 跨 partition / ATTACH 鎖太久 / partition 數爆 / DETACH 不 reclaim 空間）、跟 autovacuum &#43; index 設計整合">partitioning</a> 整合</h3>
<p>partitioned table 的 logical replication：</p>
<ol>
<li>PG 13+ <code>publish_via_partition_root = true</code> — publication 從 parent 角度看、不是 per-partition</li>
<li>Subscriber 端可 partition 不同 strategy（甚至不 partition）</li>
<li>Schema change 對 partition table 更複雜、走 expand-contract 嚴格</li>
</ol>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Logical replication conflict</strong>：subscriber 端寫衝突的處理（PG 17+ 加 conflict resolution）</li>
<li><strong>bi-directional replication（pg_active）</strong>：多 region active-active、衝突解決設計</li>
<li><strong>Decoder plugin 對比</strong>：pgoutput / wal2json / decoderbufs 效能跟易用性</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a></li>
<li>上游 chapter：<a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">Schema Migration Rollout Evidence</a> — schema change × CDC 對應</li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> / <a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL Archiving</a> / <a href="/blog/backend/01-database/vendors/postgresql/replication-slot-management/" data-link-title="PostgreSQL Replication Slot Management：Physical / Logical / Failover Slot 治理" data-link-desc="PG replication slot 是 *primary 端的 standby 進度紀錄*、防 WAL premature deletion。但 orphan slot 會吃 disk、failover 後 logical slot 不會自動跟新 primary、是 PG 操作的 hidden complexity。本文走 physical / logical slot 差異、slot lifecycle、failover slot synchronization（PG 17&#43; 新特性）、orphan slot 治理、5 production 踩雷（orphan slot disk 爆 / logical slot lag / failover 後 slot 丟 / wal_keep_size 跟 slot 衝突 / connection 同時打 slot 數量限制）">Replication Slot Management</a>（slot lifecycle / orphan / failover sync）/ <a href="/blog/backend/01-database/vendors/postgresql/replication-topology/" data-link-title="PostgreSQL Replication Topology：async / sync / quorum 三模式跟 LSN &#43; replication slot 的三軸組合" data-link-desc="PostgreSQL streaming replication 不是「sync 或 async」、是 *durability / latency / consistency* 三軸組合 &#43; LSN-based 進度追蹤 &#43; replication slot 治理。本文走 3 軸取捨模型、async / sync / quorum-based sync 行為對比、LSN &#43; replication slot 機制、配置 step-by-step、5 production 踩雷（standby lag 暴衝 / sync standby 退回 async / orphan replication slot / cascading replication 雪崩 / failover 後 timeline 分歧）、跟 Patroni HA &#43; logical replication 整合">Replication Topology</a>（streaming + LSN 基礎）</li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>MongoDB Change Streams + Kafka 整合：resume token、scope 選擇與 connector 治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/change-streams-kafka/</guid><description>&lt;p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>MongoDB 適用度前置判讀&lt;/strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>、本篇不重複展開。Change streams 是 &lt;em>已選 MongoDB 後&lt;/em> 的 event-driven 整合議題。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷&lt;/h2>
&lt;p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：&lt;/p>
&lt;ul>
&lt;li>Downstream 漏 event 或 duplicate event&lt;/li>
&lt;li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌&lt;/li>
&lt;li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event&lt;/li>
&lt;/ul>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>MongoDB Kafka Connector log &lt;code>ChangeStreamHistoryLost&lt;/code> 或 &lt;code>ResumeTokenChanged&lt;/code>&lt;/li>
&lt;li>Downstream Kafka topic event count vs source collection write count 不平&lt;/li>
&lt;li>Replication oplog 跟 change stream consumer 的 lag 同時升&lt;/li>
&lt;/ul>
&lt;p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration&lt;/a>（pipeline-level migration 經驗對照）。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB change streams 是 3.6+ 原生 CDC 介面、本質上是 oplog tail 包裝成 cursor API。Application 從 dual-write 模式（自己寫 MongoDB 又寫 Elasticsearch / Redis / data warehouse）換成 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現「downstream 漏 event」或「duplicate event」；最痛的是 connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌。本文把 change stream 機制、Kafka Connector 配置、resume token 治理、sharded cluster scope 選擇講清楚。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 change streams 簡介 — 而是 production CDC pipeline 部署 + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>MongoDB 適用度前置判讀</strong>：進到 CDC pipeline 設計前先確認 workload 在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 詳見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>、本篇不重複展開。Change streams 是 <em>已選 MongoDB 後</em> 的 event-driven 整合議題。</p></blockquote>
<h2 id="問題情境第一版-cdc-pipeline-跑幾週的踩雷">問題情境：第一版 CDC pipeline 跑幾週的踩雷</h2>
<p>典型觸發場景：application 寫 MongoDB 後還要 dual-write Elasticsearch / Redis / data warehouse、application code 越塞越多 hook、寫入失敗的補償邏輯散落各處。改用 change stream → Kafka → downstream sink 後、有了第一版 CDC pipeline、但連續工作幾週後出現：</p>
<ul>
<li>Downstream 漏 event 或 duplicate event</li>
<li>Connector restart 後 resume token 過期（oplog 已滾掉）、整個 collection 必須重灌</li>
<li>Sharded cluster 上 collection-level change stream 跟 cluster-wide change stream 行為不同、application 連 mongos 跟連 single shard 拿到不同 event</li>
</ul>
<p>讀者徵兆：</p>
<ul>
<li>MongoDB Kafka Connector log <code>ChangeStreamHistoryLost</code> 或 <code>ResumeTokenChanged</code></li>
<li>Downstream Kafka topic event count vs source collection write count 不平</li>
<li>Replication oplog 跟 change stream consumer 的 lag 同時升</li>
</ul>
<p>Case anchor：CDC pipeline resume token 過期導致全量重灌的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」+ 容量公式處理、不憑空編造 incident 數字。側面引用 <a href="/blog/backend/09-performance-capacity/cases/spotify-kafka-to-pubsub-migration-gcp/" data-link-title="9.C9 Spotify：從自管 Kafka 遷移到 GCP Pub/Sub 的事件交付系統" data-link-desc="Spotify 把自管 Kafka 事件系統遷移到 Google Cloud Pub/Sub、避免自管 broker 的容量規劃成本">Spotify Kafka → PubSub migration</a>（pipeline-level migration 經驗對照）。</p>
<h2 id="核心機制">核心機制</h2>
<p>Change stream 是 MongoDB 3.6+ 原生 CDC、本質上是 oplog tail 包裝成 cursor API。可以從 collection / database / cluster 三個 scope 開：</p>
<ul>
<li><strong>Collection-level</strong>：監看單一 collection 的變更</li>
<li><strong>Database-level</strong>：監看整個 database 的所有 collection</li>
<li><strong>Cluster-wide</strong>：監看整個 cluster 的所有 database</li>
</ul>
<p>Oplog 是 capped collection、預設 size = disk 5% 或 50GB（取較小）。Resume token 對應 oplog entry 的 timestamp + UUID + documentKey。Token 必須對應仍在 oplog 內的 entry — oplog 滾掉就拿不到 token 對應的位置、<code>ChangeStreamHistoryLost</code>。</p>
<p><strong>Resume token 兩種用法</strong>：</p>
<ul>
<li><code>_id</code>：每個 event 都帶、application 自己存</li>
<li><code>startAfter</code> / <code>resumeAfter</code> parameter：重啟 cursor 時帶上</li>
</ul>
<p><strong><code>fullDocument: &quot;updateLookup&quot;</code></strong>：update event 預設只給 delta、加這個 option 會額外 query 一次 primary 拿完整 doc；高頻 update 下成本顯著（primary 負擔翻倍）。</p>
<p><strong>Pre-image / post-image（6.0+）</strong>：可以拿到 update 前的 doc 狀態、需 collection-level option <code>changeStreamPreAndPostImages: true</code>。</p>
<p><strong>Cluster-wide vs collection-level change stream</strong>：</p>
<ul>
<li>Cluster-wide 必須打 mongos、event ordering 是 global</li>
<li>Collection-level 可直接打單 shard、ordering 只在該 shard 內</li>
<li>Sharded cluster 上 cluster-wide stream 容易把 mongos 變單點瓶頸（所有 shard 的 event 都收斂到 mongos）</li>
</ul>
<p><strong>MongoDB Kafka Connector</strong>（Confluent / MongoDB 官方）：</p>
<ul>
<li>Source connector：把 change stream → Kafka topic</li>
<li>Sink connector：把 Kafka topic → MongoDB</li>
<li>At-least-once 語義、需 application 處理 idempotency</li>
</ul>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a>、<a href="/blog/backend/knowledge-cards/replication-channel/" data-link-title="Replication Channel" data-link-desc="說明多來源複製中，每個來源對應的獨立複製通道如何成為隔離單位">replication-channel</a>、<a href="/blog/backend/knowledge-cards/replication-slot/" data-link-title="Replication Slot" data-link-desc="說明邏輯複製如何用 slot 追蹤消費進度，並對來源端造成保留壓力">replication-slot</a>（MongoDB 沒 slot、概念對照）。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：scope 決策樹</strong>。</p>
<table>
  <thead>
      <tr>
          <th>Scope</th>
          <th>適用條件</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Collection-level</td>
          <td>單一 collection 的下游 sink、ordering 需求單一</td>
          <td>多 collection 要多 connector</td>
      </tr>
      <tr>
          <td>Database-level</td>
          <td>多 collection 共享 sink、ordering 跨 collection</td>
          <td>filter cost 在 connector 端</td>
      </tr>
      <tr>
          <td>Cluster-wide</td>
          <td>整個 cluster 統一 audit / replay</td>
          <td>mongos 單點瓶頸風險、event 量大</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 2：oplog sizing</strong>。容量公式：</p>





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">pipeline</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;operationType&#34;</span><span class="o">:</span> <span class="p">{</span> <span class="nx">$in</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;insert&#34;</span><span class="p">,</span> <span class="s2">&#34;update&#34;</span><span class="p">,</span> <span class="s2">&#34;delete&#34;</span><span class="p">]</span> <span class="p">}</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">{</span> <span class="nx">$match</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;fullDocument.region&#34;</span><span class="o">:</span> <span class="s2">&#34;ap-tokyo&#34;</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kr">const</span> <span class="nx">changeStream</span> <span class="o">=</span> <span class="nx">db</span><span class="p">.</span><span class="nx">orders</span><span class="p">.</span><span class="nx">watch</span><span class="p">(</span><span class="nx">pipeline</span><span class="p">)</span></span></span></code></pre></div><p>把過濾下推減少 connector 處理量、特別是高頻 collection 上。</p>
<p><strong>Step 6：downstream idempotency</strong>。Sink 收 Kafka event 時用 <code>documentKey._id + clusterTime</code> 做 dedup key — at-least-once 語義意味著 connector restart 後幾分鐘 event 會重發。</p>
<p>驗證點：</p>
<ul>
<li>Source collection write count vs Kafka topic event count 差異 &lt; 0.1%</li>
<li>Resume token age &lt; oplog retention 的 50%（健康狀態）</li>
<li>Connector restart drill 能 5 分鐘內接回</li>
</ul>
<p>Rollback boundary：source connector 是 read-only 對 MongoDB 無傷；sink connector 要備份 target 才能還原；resume token 寫錯 → 從 <code>startAtOperationTime</code> 回退到時間點重跑。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>Resume token 過期（oplog 滾掉）</strong>：connector down 太久、oplog 已超出 retention、<code>ChangeStreamHistoryLost</code> → 必須 <code>copy.existing</code> 全量重灌、期間 downstream 看不到新資料。預防是 oplog sizing 留 buffer + connector lag alarm + token age 監控（age &gt; oplog retention 的 50% 預警）。</p>
<p><strong>updateLookup 在高頻 update 下打爆 primary</strong>：每筆 update event 都觸發一次 primary query、primary 負擔翻倍。修法是改 collection-level pre/post image（6.0+）、由 MongoDB 自己在寫入時記錄、或在 application 補完整 doc 後再寫 Kafka、不用 updateLookup。</p>
<p><strong>Sharded cluster cluster-wide stream 打爆 mongos</strong>：所有 shard 的 event 都收斂到 mongos、mongos 變單點瓶頸。修法是改 collection-level stream 多 connector 並行、每 connector 連 mongos 但只訂單一 collection。</p>
<p><strong>At-least-once 變 duplicate flood</strong>：connector restart 點之後幾分鐘 event 重發、downstream 沒做 idempotency → 重複 side effect（重複發 email、重複扣款）。修法是 sink 端強制 idempotency（dedup key 寫 Redis / DB）、不能假設「我用 at-least-once 但實際不會 duplicate」。</p>
<p><strong>Schema drift 突然 break sink</strong>：MongoDB 寫了新欄位 / 改型別、sink connector 的 JSON schema 不認、batch 停在 dead-letter queue。修法是 schema 變動有 validation gate（見 <a href="../schema-design-pattern/">schema design pattern</a>）、sink schema 設 <code>lenient</code> 模式吃 unknown field、或加 schema registry 統一版本。</p>
<p><strong>Backup / DDL 期間 change stream 異常</strong>：<code>reIndex</code> / <code>compact</code> / <code>dropCollection</code> 觸發特殊 event、connector 沒處理 → consumer 停。修法是 connector 處理特殊 event 邏輯要明確、不認得的 operation type 至少 log warning 而不是 silently stuck。</p>
<p>Anti-recommendation：</p>
<ul>
<li>簡單的 <a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a> + application transactional write 對於低吞吐 / 單 sink 的場景比 change stream + Kafka 簡單；不是所有「需要 event 通知」的場景都要 CDC pipeline</li>
<li>若 downstream 只是同一 region 同團隊的 Elasticsearch index、<code>$merge</code> 寫進中介 collection 或 application 雙寫 + 對賬可能成本更低</li>
<li>Resume token 過期是這條路徑最痛的事故、oplog sizing 是 <em>投資而不是成本</em> — 不要為了省 storage 把 oplog 設太小</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Oplog 健康</strong>：oplog 寫入速率與保留時間</li>
<li><strong>Change stream 健康</strong>：cursor age、resume token 距 oplog 頭尾的距離</li>
<li><strong>Connector 健康</strong>：connector lag（Kafka offset 對比 source write）</li>
<li><strong>下游健康</strong>：event count diff（source write count vs sink apply count）、event time → arrival time lag 分布</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>db.getReplicationInfo()</code>：oplog 大小 / 時間範圍</li>
<li><code>db.printReplicationInfo()</code>：oplog 摘要</li>
<li><code>db.currentOp({ &quot;op&quot;: &quot;getmore&quot;, &quot;ns&quot;: &quot;local.oplog.rs&quot; })</code>：看 change stream consumer 連線</li>
</ul>
<p>Connector metric（Kafka Connect JMX）：<code>source-record-poll-rate</code>、<code>source-record-write-rate</code>、<code>offset-commit-success-rate</code>。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：oplog retention + connector lag + dedup rate 是 CDC pipeline 健康狀態 evidence 三件套。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：CDC lag 升高時區分 (a) source oplog 寫太快 (b) connector 處理慢 (c) downstream sink 慢。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../shard-key-selection/">shard key selection</a> — cluster-wide vs collection-level change stream 在 sharded cluster 的選擇</li>
<li><a href="../replica-set-read-preference/">replica set read preference</a> — change stream 對 primary load 的影響、能否走 secondary</li>
<li><a href="../schema-design-pattern/">schema design pattern</a> — schema validator 對下游 sink 的契約意義</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — CDC sink 在 production 跨層架構裡的角色（cache invalidation / federated DB 同步）</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li>MongoDB → 其他 sink 的 bulk migration 走 <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">→ Atlas Migration Service</a></li>
<li>遷出 MongoDB 時 change stream 是 catch-up 機制（先 bulk export、再 change stream 補增量）</li>
</ul>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/schema-migration-rollout-evidence/" data-link-title="1.7 Schema Migration Rollout 證據（Schema Migration Rollout Evidence）實作示範" data-link-desc="以訂單付款狀態欄位演進示範 schema migration 如何產出 evidence、release gate 與 incident decision log。">1.7 schema migration rollout evidence</a> 處理 schema drift 時 CDC pipeline 的對賬；<a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">1.9 reconciliation data repair</a> 處理 CDC 失準後的對賬流程。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「change streams + Kafka」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/changeStreams/">Change Streams</a>、<a href="https://www.mongodb.com/docs/kafka-connector/current/">MongoDB Kafka Connector</a>、<a href="https://www.mongodb.com/docs/manual/core/replica-set-oplog/">Oplog</a></li>
</ul>
]]></content:encoded></item><item><title>DynamoDB Streams 與 Lambda 事件驅動：CDC、shard 順序保證、消費模式與失敗處理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/streams-lambda-event-driven/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/streams-lambda-event-driven/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB&lt;/a> overview 的 implementation-layer deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>訂單寫進 DynamoDB 後、搜尋索引要更新、快取要失效、要推一筆通知、要寫一筆 audit。第一版 application 在寫訂單的同一段 code 裡同步做完這四件事、結果單一步驟（推通知的外部 API）變慢、整個寫訂單路徑被拖垮。第二版改成「另一個 service 每 10 秒輪詢 table 撈新資料」、輪詢既貴（全表 scan）又慢（最差 10 秒延遲）。兩個痛點都指向同一個缺口 — 資料變更需要一條可靠、低延遲、不污染寫路徑的下游通道。這正是 DynamoDB Streams 的責任。本文展開 Streams 的 record 結構、順序保證的真實邊界、消費模式選擇與失敗處理。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>事件機制前提：先確認 workload 適配 DynamoDB&lt;/strong>：事件驅動機制是已選 DynamoDB 後的議題；選型本身先過 workload 適配 4 軸 — PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定。判讀軸詳見 &lt;a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀&lt;/a>。本文聚焦 &lt;em>已選 DynamoDB&lt;/em> 後、把資料變更導向下游的事件機制。&lt;/p>&lt;/blockquote>
&lt;h2 id="核心機制stream-record-與-view-type">核心機制：Stream record 與 view type&lt;/h2>
&lt;p>DynamoDB Streams 是 table 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change data capture&lt;/a> 通道 — 把 item 層級的 insert / modify / delete 變成一條時間排序的事件流。開啟後、每筆寫入產生一筆 stream record。&lt;/p>
&lt;p>&lt;strong>view type 決定 record 帶什麼&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>StreamViewType&lt;/th>
 &lt;th>record 內容&lt;/th>
 &lt;th>典型用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>KEYS_ONLY&lt;/code>&lt;/td>
 &lt;td>只有被改 item 的 key&lt;/td>
 &lt;td>下游自己回查、最省&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>NEW_IMAGE&lt;/code>&lt;/td>
 &lt;td>寫入後的完整新 item&lt;/td>
 &lt;td>同步到搜尋索引 / 快取&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>OLD_IMAGE&lt;/code>&lt;/td>
 &lt;td>寫入前的舊 item&lt;/td>
 &lt;td>audit「改了什麼」、刪除留底&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>NEW_AND_OLD_IMAGES&lt;/code>&lt;/td>
 &lt;td>新舊都帶&lt;/td>
 &lt;td>算 diff、條件性下游處理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>view type 在開 stream 時定、改要重開 stream。選 &lt;code>NEW_AND_OLD_IMAGES&lt;/code> 最方便但 record 最大（影響 Lambda payload 與成本）；下游只需 key 就回查的、選 &lt;code>KEYS_ONLY&lt;/code>。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Scope warning&lt;/strong>：「stream record 保留 24 小時」、「Lambda 單次 batch 上限」這些屬 AWS vendor 規格、會隨版本調整、實作時 cross-verify 官方 doc。本文不含 production case 揭露的 stream 配置數字。&lt;/p>&lt;/blockquote>
&lt;p>對應 knowledge card：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a>。&lt;/p>
&lt;h2 id="順序保證的真實邊界">順序保證的真實邊界&lt;/h2>
&lt;p>這是 Streams 最常被誤解的點 — 「stream 是有序的」這句話只在特定範圍成立。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB</a> overview 的 implementation-layer deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。</p></blockquote>
<p>訂單寫進 DynamoDB 後、搜尋索引要更新、快取要失效、要推一筆通知、要寫一筆 audit。第一版 application 在寫訂單的同一段 code 裡同步做完這四件事、結果單一步驟（推通知的外部 API）變慢、整個寫訂單路徑被拖垮。第二版改成「另一個 service 每 10 秒輪詢 table 撈新資料」、輪詢既貴（全表 scan）又慢（最差 10 秒延遲）。兩個痛點都指向同一個缺口 — 資料變更需要一條可靠、低延遲、不污染寫路徑的下游通道。這正是 DynamoDB Streams 的責任。本文展開 Streams 的 record 結構、順序保證的真實邊界、消費模式選擇與失敗處理。</p>
<blockquote>
<p><strong>事件機制前提：先確認 workload 適配 DynamoDB</strong>：事件驅動機制是已選 DynamoDB 後的議題；選型本身先過 workload 適配 4 軸 — PK 天然均勻 / control plane vs data plane / consistency 可接受 eventual / access pattern 穩定。判讀軸詳見 <a href="../single-table-design-pattern/#dynamodb-%e9%81%a9%e7%94%a8%e5%ba%a6%e5%89%8d%e7%bd%ae%e5%88%a4%e8%ae%804-%e8%bb%b8">single-table-design-pattern 開頭 4 軸前置判讀</a>。本文聚焦 <em>已選 DynamoDB</em> 後、把資料變更導向下游的事件機制。</p></blockquote>
<h2 id="核心機制stream-record-與-view-type">核心機制：Stream record 與 view type</h2>
<p>DynamoDB Streams 是 table 的 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change data capture</a> 通道 — 把 item 層級的 insert / modify / delete 變成一條時間排序的事件流。開啟後、每筆寫入產生一筆 stream record。</p>
<p><strong>view type 決定 record 帶什麼</strong>：</p>
<table>
  <thead>
      <tr>
          <th>StreamViewType</th>
          <th>record 內容</th>
          <th>典型用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>KEYS_ONLY</code></td>
          <td>只有被改 item 的 key</td>
          <td>下游自己回查、最省</td>
      </tr>
      <tr>
          <td><code>NEW_IMAGE</code></td>
          <td>寫入後的完整新 item</td>
          <td>同步到搜尋索引 / 快取</td>
      </tr>
      <tr>
          <td><code>OLD_IMAGE</code></td>
          <td>寫入前的舊 item</td>
          <td>audit「改了什麼」、刪除留底</td>
      </tr>
      <tr>
          <td><code>NEW_AND_OLD_IMAGES</code></td>
          <td>新舊都帶</td>
          <td>算 diff、條件性下游處理</td>
      </tr>
  </tbody>
</table>
<p>view type 在開 stream 時定、改要重開 stream。選 <code>NEW_AND_OLD_IMAGES</code> 最方便但 record 最大（影響 Lambda payload 與成本）；下游只需 key 就回查的、選 <code>KEYS_ONLY</code>。</p>
<blockquote>
<p><strong>Scope warning</strong>：「stream record 保留 24 小時」、「Lambda 單次 batch 上限」這些屬 AWS vendor 規格、會隨版本調整、實作時 cross-verify 官方 doc。本文不含 production case 揭露的 stream 配置數字。</p></blockquote>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">change-data-capture</a>、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>。</p>
<h2 id="順序保證的真實邊界">順序保證的真實邊界</h2>
<p>這是 Streams 最常被誤解的點 — 「stream 是有序的」這句話只在特定範圍成立。</p>
<p><strong>保證範圍</strong>：</p>
<ul>
<li>stream 切成多個 shard、每個 shard 對應 table 的一組 partition</li>
<li><strong>同一 partition key 的所有變更、進同一個 shard、在 shard 內嚴格時間排序</strong></li>
<li>跨 shard <em>沒有</em> 全域順序保證</li>
</ul>
<p>這代表：同一筆訂單（同 PK）的 create → update → delete 一定按序到下游；但訂單 A 跟訂單 B（不同 PK、可能不同 shard）的相對順序不保證。下游若依賴「跨實體的全域順序」、會踩雷。</p>
<p><strong>shard split / merge</strong>：</p>
<p>table partition 會隨資料量與流量 split、stream shard 跟著變動。消費端要能處理 shard 生命週期（Lambda event source mapping 自動處理；自己用 SDK 拉的要處理 shard iterator 的 parent-child 關係）。</p>
<p><strong>順序 + 冪等的組合</strong>：</p>
<p>Lambda 消費 stream 是 <em>at-least-once</em> — 同一筆 record 可能被送兩次（retry、shard 重平衡）。下游處理必須冪等：用 record 的 sequence number 或業務鍵去重、不能假設「每筆只處理一次」。每筆訊息帶獨立 message_id 的事件流天然適合 — message_id 當冪等鍵、重送不重複發。</p>
<blockquote>
<p><strong>Scope warning</strong>：上述順序與 at-least-once 語意屬 Streams vendor 規格 + 通用事件處理工程、非 production case 揭露。</p></blockquote>
<h2 id="消費模式lambda-vs-kinesis">消費模式：Lambda vs Kinesis</h2>
<p>兩條主要消費路徑、責任與運維成本不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Lambda event source mapping</th>
          <th>Kinesis Data Streams for DynamoDB</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模式</td>
          <td>push（DynamoDB 觸發 Lambda）</td>
          <td>pull（消費端自己拉）</td>
      </tr>
      <tr>
          <td>retention</td>
          <td>stream 原生較短</td>
          <td>較長（可重播更久）</td>
      </tr>
      <tr>
          <td>消費者數</td>
          <td>適合單一 / 少量消費者</td>
          <td>適合多消費者 fan-out</td>
      </tr>
      <tr>
          <td>運維</td>
          <td>幾乎零（managed trigger）</td>
          <td>要管 Kinesis consumer / KCL</td>
      </tr>
      <tr>
          <td>重播能力</td>
          <td>受 stream retention 限制</td>
          <td>retention 內可重播</td>
      </tr>
  </tbody>
</table>
<p>多數「寫入後觸發一個下游動作」用 Lambda event source mapping 最簡單。需要長 retention、多消費者 fan-out、或要重播歷史變更的、用 Kinesis Data Streams for DynamoDB。</p>
<p><strong>Lambda event source mapping 的關鍵旋鈕</strong>：</p>
<ul>
<li>batch size：一次給 Lambda 幾筆 record（吞吐 vs 延遲）</li>
<li>batch window：湊滿 batch 或等多久才觸發（低流量時的延遲控制）</li>
<li>parallelization factor：一個 shard 並行幾個 Lambda（提升單 shard 吞吐、但犧牲 shard 內嚴格順序）</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：parallelization factor &gt; 1 會在單 shard 內並行處理、放寬順序保證；需要嚴格順序的維持 factor = 1。具體上限屬 vendor 規格。</p></blockquote>
<h2 id="操作流程">操作流程</h2>
<p>從開 stream 到下游上線的 6 步流程。</p>
<h4 id="step-1選-view-type">Step 1：選 view type</h4>
<p>依下游需要什麼決定。同步到搜尋索引要完整新 item → <code>NEW_IMAGE</code>；audit 要看改動 → <code>NEW_AND_OLD_IMAGES</code>；下游自己回查 → <code>KEYS_ONLY</code>。</p>
<h4 id="step-2開-stream">Step 2：開 stream</h4>





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 灌一筆寫入、確認下游在預期延遲內收到對應 record</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># CloudWatch: Lambda IteratorAge（消費落後程度）應接近 0</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 製造一筆會失敗的 record、確認進 DLQ 而非卡住整個 shard</span></span></span></code></pre></div><p><strong>Rollback boundary</strong>：關 stream 即停止產生新 record；已產生的 record 在 retention 內仍存在。下游邏輯出錯時、修好 Lambda 後可在 retention 內讓未處理 record 重新消費（或從 DLQ 重放）。</p>
<h2 id="失敗模式">失敗模式</h2>
<p>production 常見的 5 個踩雷：</p>
<h4 id="case-1下游非冪等重送導致重複副作用">Case 1：下游非冪等、重送導致重複副作用</h4>
<p>at-least-once 重送、下游每次都發一筆通知、用戶收到重複推播。修法：下游用業務鍵冪等、sequence number 去重；副作用（發通知 / 扣款）必須 idempotent。</p>
<h4 id="case-2依賴跨實體全域順序">Case 2：依賴跨實體全域順序</h4>
<p>下游假設「所有訂單事件按全域時間到達」、實際跨 shard 無此保證、算錯聚合。修法：只依賴「同 PK 內有序」；需要跨實體順序的、在下游用 event timestamp 重排、或重新設計不依賴全域順序。</p>
<h4 id="case-3毒丸-record-卡住整個-shard">Case 3：毒丸 record 卡住整個 shard</h4>
<p>某筆 record 讓 Lambda 永遠拋例外、預設行為是重試整個 batch、shard 卡死、IteratorAge 無限上升。修法：開 <code>BisectBatchOnFunctionError</code> + <code>MaximumRetryAttempts</code> + DLQ、隔離壞 record 讓其餘繼續。</p>
<h4 id="case-4consumer-落後record-過期遺失">Case 4：consumer 落後、record 過期遺失</h4>
<p>下游處理太慢、IteratorAge 超過 stream retention、未處理 record 被清掉。這個 Case 的代價跟前三個不同層級：前三個是「重複副作用 / 算錯聚合 / shard 卡住」、都還在 stream 裡留有 record、修好邏輯後可重新消費或從 DLQ 重放。Case 4 是 record 本身已被 retention 清除、那段時間的資料變更在 stream 這條通道上永久消失、沒有回退路徑。要補回只能反向比對 table 當前狀態跟下游狀態（若下游存得了），或在源頭重跑一次寫入觸發新 record — 兩者都是事故後的人工修復、成本遠高於前三個 Case 的設定旋鈕。</p>
<p>因為不可逆、防線要前置在「逼近 retention 之前」而非「過期之後」：IteratorAge alarm 的閾值設在遠低於 retention 的水位、留出擴容反應時間；吞吐不足時加 parallelization factor 或改 Kinesis（更長 retention、爭取更大的落後緩衝）；下游設計要能水平擴、讓落後可被快速追平。</p>
<h4 id="case-5parallelization-factor-開了還抱怨順序錯">Case 5：parallelization factor 開了還抱怨順序錯</h4>
<p>為提吞吐把 factor 開 &gt; 1、又依賴 shard 內嚴格順序、兩者矛盾。修法：需要嚴格順序維持 factor = 1；要並行吞吐就接受順序放寬、或把順序敏感的處理移到下游用 PK 分組。</p>
<p><strong>Anti-recommendation</strong>：只有單一同步下游、且寫路徑延遲容忍度高 → 直接在 application 寫入後同步處理可能更簡單、不必引入 stream 的運維與冪等複雜度。Streams 的價值在「多下游 / 解耦寫路徑 / 低延遲 CDC」。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<p>CloudWatch metric：</p>
<ul>
<li><code>IteratorAge</code>（Lambda）：消費落後程度、最關鍵指標、持續上升代表下游跟不上</li>
<li>Lambda <code>Errors</code> / <code>Throttles</code>：下游處理失敗 / 被限流</li>
<li>DLQ 訊息數：毒丸 record 累積、需要人工介入</li>
<li>stream <code>ReadProvisionedThroughputExceeded</code>（Kinesis 模式）：消費端讀超限</li>
</ul>
<p><strong>判讀</strong>：</p>
<ul>
<li><code>IteratorAge</code> 接近 retention 上限 → 資料變更即將遺失、緊急擴消費端</li>
<li>DLQ 持續累積 → 有系統性壞 record、查 Lambda 邏輯或上游資料</li>
<li>Errors 尖峰但 IteratorAge 正常 → transient 失敗、retry 有在吸收</li>
</ul>
<blockquote>
<p><strong>Scope warning</strong>：本文未引用 production case 的 stream metric 數字；上述指標與判讀屬 vendor 規格 + 通用事件處理觀測。</p></blockquote>
<p>接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a>、<a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a>。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="streams-跟-03-訊息佇列的責任切分">Streams 跟 03 訊息佇列的責任切分</h3>
<p>DynamoDB Streams 是 <em>資料庫變更</em> 的 CDC 通道、不是通用訊息佇列。兩者責任不同：</p>
<ul>
<li><strong>Streams</strong>：源頭是 table 寫入、record 由 DynamoDB 自動產生、生命週期綁 table、retention 短</li>
<li><strong>訊息佇列（SQS / SNS / Kafka）</strong>：源頭是 application 主動 publish、用於通用解耦、retention 與語意更彈性</li>
</ul>
<p>典型組合：Streams 捕捉 table 變更 → Lambda 處理 → 需要扇出到多個獨立服務時、再 publish 到 SNS / EventBridge。當事件來源不是「資料庫變更」而是「業務事件」、直接用 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列模組</a> 的 queue / topic、不要硬塞進 table 再用 stream。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<ul>
<li><a href="/blog/backend/01-database/vendors/dynamodb/transactions-conditional-writes/" data-link-title="DynamoDB Transaction 與 Conditional Write：跨 item 原子性、optimistic locking 與 idempotency" data-link-desc="DynamoDB 的寫原子性不是免費 ACID；本文展開 TransactWriteItems 跨 item 原子性、ConditionExpression 條件寫、version-based optimistic locking、ClientRequestToken idempotency，以及 transaction 2x 成本邊界與何時用單 item conditional write 取代 transaction">transactions-conditional-writes</a> — transaction 寫入也觸發 stream、下游處理要冪等</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/single-table-design-pattern/" data-link-title="DynamoDB Single-Table Design：從適用度前置判讀到 access pattern 反推 PK/SK" data-link-desc="DynamoDB single-table 設計不是「資料表越少越好」，而是 access pattern 反推 PK/SK 跟 GSI；本文先做 DynamoDB 適用度 4 軸前置判讀（PK 天然均勻 / control plane vs data plane / consistency / access pattern 穩定），再展開設計流程、failure modes 與 durable queue 正向用例">single-table-design-pattern</a> — single-table 下不同 entity 共用 stream、下游用 type 欄位分流</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/global-tables-conflict/" data-link-title="DynamoDB Global Tables：multi-region active-active、LWW conflict 與 cross-device sync 正向用例" data-link-desc="Global Tables 不只是 conflict 痛點、也是 cross-device sync / global read / DR failover 的正向工程方案；本文展開 B2B SaaS vs B2C 業務 driver、LWW conflict resolution、reconciliation pipeline，含 Genesys 99.999% 跨 15 region 跟 Disney&#43; 跨裝置同步的對照">global-tables-conflict</a> — Global Tables 跨 region 複製本身基於 stream 機制</li>
<li>替代路由：通用業務事件 / 多消費者扇出 / 長 retention → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列模組</a></li>
<li>搜尋索引同步下游 → OpenSearch / Elasticsearch（DynamoDB 不適合做全文檢索）</li>
<li>跟 <a href="/blog/backend/09-performance-capacity/cases/paypay-mobile-payment-messaging/" data-link-title="9.C26 PayPay：行動支付每日 3 億訊息的 DynamoDB 後端" data-link-desc="日本最大行動支付 PayPay 每日 3 億訊息、用 DynamoDB 處理通知與訊息功能、支撐次秒級反應">PayPay 9.C26</a> 互引：訊息事件 message_id 天然冪等、適合 stream 下游處理</li>
</ul>
]]></content:encoded></item><item><title>Cosmos DB Change Feed (CDC)：persistent change log、Azure Functions trigger、latest-version vs all-versions-and-deletes 與跟 DynamoDB Streams 對照</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/change-feed-cdc/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/change-feed-cdc/</guid><description>&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB&lt;/a> overview 的 deep article、寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology&lt;/a>。Change Feed 是 Cosmos DB 把 container 內每次寫入按 logical partition 順序持久化成一條可重讀變更序列的能力、對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture&lt;/a> 的概念分層。它讓「寫入後要做的後續工作」（投影、cache 失效、事件發布、跨 store 同步）從 application 寫入路徑解耦出來、由獨立 consumer 按自己的進度消費。本文先講 Change Feed 的精確語義與兩種模式、再進 change feed processor 與 Azure Functions trigger 的操作流程、最後拆失敗模式與跟 DynamoDB Streams 的對照。&lt;/p>
&lt;p>Case anchor 是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS&lt;/a>（85,000 SKU、每週新增 5,000 件的高更新頻率 catalog、寫入後需要 search index / 推薦排序投影）。ASOS case 本身沒有揭露 Change Feed 的實作細節、本文只取它的 catalog 寫入投影壓力當情境 anchor、機制以 Azure vendor 規格與通用工程展開。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：catalog 寫入 Cosmos DB 後、下游還有一連串工作要做 — 把商品同步到 search index、刷新推薦排序、讓 cache 失效、發 event 給庫存服務。團隊一開始把這些工作塞進寫入 API 的同步路徑、寫一筆商品要等 search index 更新完才返回、寫入 latency 被下游拖垮；高峰時下游 search service 變慢、整條寫入鏈一起阻塞。&lt;/p>
&lt;p>讀者徵兆：&lt;/p>
&lt;ul>
&lt;li>「寫入 API latency 被下游投影工作拖高、想把它非同步化」&lt;/li>
&lt;li>「下游 consumer 掛掉一段時間、重啟後要怎麼補回漏掉的變更」&lt;/li>
&lt;li>「同一筆 document 在短時間內改三次、下游只需要最終狀態還是每次都要」&lt;/li>
&lt;li>「要做 audit / 要知道刪除事件、但 Change Feed 預設讀不到 delete」&lt;/li>
&lt;/ul>
&lt;p>真實壓力：寫入路徑與下游處理耦合會讓寫入 SLA 受制於最不穩的 consumer；而把投影改成「掃全表」的 batch job 又有延遲與成本問題。Change Feed 提供的是 &lt;em>持久、可重讀、按 partition 有序&lt;/em> 的變更來源、讓下游用 pull 或 trigger 模式按自己的進度消費。&lt;/p>
&lt;h2 id="核心機制partition-scoped-persistent-change-log">核心機制：partition-scoped persistent change log&lt;/h2>
&lt;p>Change Feed 是 container 的內建能力、把每個 logical partition 內的寫入按發生順序記錄成一條持久序列。它的關鍵語義有幾個面向。&lt;/p>
&lt;p>順序保證是 &lt;em>per logical partition&lt;/em>、不是 container 全域。同一 partition key 內的變更嚴格有序、跨 partition 之間沒有全域順序 — 這跟 &lt;a href="../partition-key-design/">partition-key-design&lt;/a> 的設計直接相關、consumer 必須假設不同 partition 的事件可能交錯到達。&lt;/p></description><content:encoded><![CDATA[<p>本文是 <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB</a> overview 的 deep article、寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">vendor deep article methodology</a>。Change Feed 是 Cosmos DB 把 container 內每次寫入按 logical partition 順序持久化成一條可重讀變更序列的能力、對應 <a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture</a> 的概念分層。它讓「寫入後要做的後續工作」（投影、cache 失效、事件發布、跨 store 同步）從 application 寫入路徑解耦出來、由獨立 consumer 按自己的進度消費。本文先講 Change Feed 的精確語義與兩種模式、再進 change feed processor 與 Azure Functions trigger 的操作流程、最後拆失敗模式與跟 DynamoDB Streams 的對照。</p>
<p>Case anchor 是 <a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS</a>（85,000 SKU、每週新增 5,000 件的高更新頻率 catalog、寫入後需要 search index / 推薦排序投影）。ASOS case 本身沒有揭露 Change Feed 的實作細節、本文只取它的 catalog 寫入投影壓力當情境 anchor、機制以 Azure vendor 規格與通用工程展開。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：catalog 寫入 Cosmos DB 後、下游還有一連串工作要做 — 把商品同步到 search index、刷新推薦排序、讓 cache 失效、發 event 給庫存服務。團隊一開始把這些工作塞進寫入 API 的同步路徑、寫一筆商品要等 search index 更新完才返回、寫入 latency 被下游拖垮；高峰時下游 search service 變慢、整條寫入鏈一起阻塞。</p>
<p>讀者徵兆：</p>
<ul>
<li>「寫入 API latency 被下游投影工作拖高、想把它非同步化」</li>
<li>「下游 consumer 掛掉一段時間、重啟後要怎麼補回漏掉的變更」</li>
<li>「同一筆 document 在短時間內改三次、下游只需要最終狀態還是每次都要」</li>
<li>「要做 audit / 要知道刪除事件、但 Change Feed 預設讀不到 delete」</li>
</ul>
<p>真實壓力：寫入路徑與下游處理耦合會讓寫入 SLA 受制於最不穩的 consumer；而把投影改成「掃全表」的 batch job 又有延遲與成本問題。Change Feed 提供的是 <em>持久、可重讀、按 partition 有序</em> 的變更來源、讓下游用 pull 或 trigger 模式按自己的進度消費。</p>
<h2 id="核心機制partition-scoped-persistent-change-log">核心機制：partition-scoped persistent change log</h2>
<p>Change Feed 是 container 的內建能力、把每個 logical partition 內的寫入按發生順序記錄成一條持久序列。它的關鍵語義有幾個面向。</p>
<p>順序保證是 <em>per logical partition</em>、不是 container 全域。同一 partition key 內的變更嚴格有序、跨 partition 之間沒有全域順序 — 這跟 <a href="../partition-key-design/">partition-key-design</a> 的設計直接相關、consumer 必須假設不同 partition 的事件可能交錯到達。</p>
<p>進度由 continuation token 表達。consumer 讀到哪裡、用一個 continuation token 標記；下次帶 token 回來、從上次的位置繼續。token 是 per partition range 的、container 做 partition split 時 token 要能跟著 range 拆分 — 這是 change feed processor 幫忙處理的部分。</p>
<p>讀取是 pull-based 持久來源、不是 push 通知。Change Feed 不主動推、是 consumer 主動拉。Azure Functions 的 Cosmos DB trigger 看起來像 push、底層仍是 trigger runtime 持續 poll Change Feed。</p>
<h3 id="兩種模式latest-version-vs-all-versions-and-deletes">兩種模式：latest-version vs all-versions-and-deletes</h3>
<p>Change Feed 有兩種模式、語義差很大、選錯會在 audit / 補償場景出問題（模式名稱與可用性屬時間敏感、查 <a href="https://learn.microsoft.com/azure/cosmos-db/change-feed">最新文件</a>）。</p>
<p>Latest-version 模式（過去稱 incremental feed）只給每個 document 的 <em>最新狀態</em>。同一 document 在兩次消費之間改了三次、consumer 只會看到最後一個版本、中間版本看不到；delete 也看不到（document 消失、feed 裡沒有對應的 tombstone）。這個模式適合「我只要把最終狀態投影到下游」的場景 — search index 同步、cache 刷新、物化視圖更新。</p>
<p>All-versions-and-deletes 模式給 <em>每一次</em> 變更、包含中間版本與 delete / TTL 過期事件。同一 document 改三次、feed 給三筆；刪掉給一筆刪除事件。這個模式適合需要完整變更歷史的場景 — audit log、event sourcing、需要對 delete 做反應的跨 store 同步。代價是事件量更大、且這個模式對 retention 與 partition 行為有額外約束（時間敏感、查文件）。</p>
<p>選擇判準：問「我需要中間版本與刪除事件嗎」。投影類工作（只要最終狀態）用 latest-version；audit 與需要對刪除反應的同步用 all-versions-and-deletes。預設選 latest-version、只有明確需要歷史與 delete 時才升級。</p>
<h3 id="change-feed-processor-的角色">change feed processor 的角色</h3>
<p>直接讀 Change Feed 要自己管 partition range、lease、continuation token、failover — 這些 plumbing 用 change feed processor library 處理。它的核心元件是 <em>lease container</em>：一個獨立的 Cosmos DB container、記錄每個 partition range 由哪個 consumer instance 處理、處理到哪個 continuation token。多個 consumer instance 共用同一個 lease container 時、processor 自動把 partition range 分配到不同 instance、達成水平擴展與 failover。</p>
<h2 id="操作流程">操作流程</h2>
<h3 id="啟用與確認">啟用與確認</h3>
<p>Change Feed 對 SQL API container 是預設啟用的、不需要額外開關（latest-version 模式）。all-versions-and-deletes 模式需要在 container 層設定、且要設 retention window。</p>





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="na">[FunctionName(&#34;SyncSearchIndex&#34;)]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">public</span> <span class="kd">static</span> <span class="kd">async</span> <span class="n">Task</span> <span class="n">Run</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">    [CosmosDBTrigger(
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">        databaseName: &#34;catalog&#34;,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">        containerName: &#34;products&#34;,
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">        Connection = &#34;CosmosConnection&#34;,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">        LeaseContainerName = &#34;leases&#34;,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">        CreateLeaseContainerIfNotExists = true)]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">IReadOnlyList</span><span class="p">&lt;</span><span class="n">Product</span><span class="p">&gt;</span> <span class="n">changes</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">ILogger</span> <span class="n">log</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">p</span> <span class="k">in</span> <span class="n">changes</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">await</span> <span class="n">searchIndex</span><span class="p">.</span><span class="n">UpsertAsync</span><span class="p">(</span><span class="n">p</span><span class="p">);</span>  <span class="c1">// idempotent</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Functions trigger 底層就是 change feed processor、lease 與 scale-out 由 Functions runtime 管。驗證：function 的 invocation count 隨寫入增加、Application Insights 看 <code>changes</code> batch size 與 lag。</p>
<h3 id="rollback-boundary">Rollback boundary</h3>
<p>Change Feed 是讀取側機制、停掉 consumer 不影響寫入。要重放：刪掉 lease container 的對應 lease（或建新 processor name）會從 container 起點或指定時間點重讀。重放前確認下游投影是 idempotent、否則重放會重複寫。</p>
<h2 id="失敗模式">失敗模式</h2>
<h3 id="把-handler-寫成非-idempotent">把 handler 寫成非 idempotent</h3>
<p>Change Feed 是 at-least-once。consumer 在處理一批後、推進 token 前 crash、重啟會重讀同一批。handler 若是「append 一筆 audit row」這種非 idempotent 操作、重放會產生重複。徵兆是下游出現重複事件、且重複數對應 consumer 重啟次數。修法是讓投影用 upsert（以 document id + version 為 key）、audit 用 dedup key、發 event 帶 idempotency key 讓下游去重 — 對應 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 的設計。</p>
<h3 id="用-latest-version-模式卻期待看到-delete">用 latest-version 模式卻期待看到 delete</h3>
<p>team 用預設 latest-version feed 做跨 store 同步、上線後發現「source 刪掉的 document、target 還在」。latest-version 模式不發 delete 事件、刪除在 feed 裡是「該 document 不再出現」、consumer 無從得知。修法是 audit / 需要刪除反應的場景改 all-versions-and-deletes 模式；或在 application 層用 soft delete（寫一個 <code>deleted: true</code> 的版本、latest-version feed 就看得到這次寫入）。</p>
<h3 id="lease-container-配置不足成為瓶頸">lease container 配置不足成為瓶頸</h3>
<p>lease container 自己也吃 RU、且 processor 對它有頻繁讀寫。lease container RU 配太低、processor 推進 token 被 throttle、表現成 Change Feed 消費 lag 升高、但 monitored container 看起來健康。徵兆是消費 lag 持續增長、診斷發現 429 來自 lease container 而非 source。修法是給 lease container 足夠 RU、把它跟 source container 的容量分開規劃、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>。</p>
<h3 id="假設-change-feed-有跨-partition-全域順序">假設 Change Feed 有跨 partition 全域順序</h3>
<p>consumer 假設事件按全域時間到達、做了依賴順序的邏輯（例如「先建立帳號事件、後消費事件」）。Change Feed 只保證 per logical partition 有序、跨 partition 交錯。徵兆是偶發的「後續事件先到、依賴的前置事件後到」。修法是讓有順序依賴的 document 落在同一 partition key、或在 consumer 端用業務 timestamp / version 做排序與 buffer、不依賴 feed 到達順序。</p>
<h3 id="anti-recommendation不是所有寫入後工作都要-change-feed">Anti-recommendation：不是所有「寫入後工作」都要 Change Feed</h3>
<p>寫入後若只是同一 request 內、同一 partition 的小量同步工作、直接在 application 寫入路徑處理、或用 stored procedure 在 partition 內做（見 <a href="../stored-procedure-trigger/">stored-procedure-trigger</a>）更簡單。Change Feed 的價值在 <em>解耦下游、可重放、水平擴展</em> — 當下游處理慢、會失敗、需要重放、或要被多個獨立 consumer 各自消費時才成立。下游工作輕、不需要重放、強耦合在寫入語義內時、引入 Change Feed + lease container 是多一層維運成本。</p>
<h2 id="容量與觀測">容量與觀測</h2>
<ul>
<li>必看 metric：Change Feed 消費 lag（最新寫入時間 vs consumer 已處理位置）、processor 每批 <code>changes</code> 數量、lease container 的 <code>NormalizedRUConsumption</code></li>
<li>consumer 端 throughput 受 partition range 數限制 — 並行度上限約等於 physical partition 數；range 不夠多時加 consumer instance 不會更快</li>
<li>成本：Change Feed 讀取本身吃 RU、all-versions-and-deletes 模式事件量更大、lease container 額外 RU — 三項都進容量公式、見 <a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a></li>
<li>回 <a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型</a>：把 Change Feed consumer 當獨立 throughput 單位、不要跟 OLTP 寫入共用同一個 RU budget 估算</li>
<li>Alert：消費 lag 持續增長（consumer 跟不上寫入）、lease container 429、handler 例外率上升</li>
</ul>
<h2 id="邊界與整合">邊界與整合</h2>
<ul>
<li>Sibling deep articles：<a href="../stored-procedure-trigger/">stored-procedure-trigger</a>（partition 內同步邏輯 vs Change Feed 的非同步解耦）、<a href="../synapse-link-federation/">synapse-link-federation</a>（分析 workload 用 analytical store、不要用 Change Feed 自己搭 analytics pipeline）、<a href="../partition-key-design/">partition-key-design</a>（per-partition 順序的來源）、<a href="../ru-cost-model-sizing/">ru-cost-model-sizing</a>（Change Feed + lease container 的 RU 成本）</li>
<li>跟 DynamoDB Streams 對照：兩者都是 partition-ordered 變更 log + at-least-once consumer。差異在 DynamoDB Streams 有固定 24 小時 retention、原生發 INSERT / MODIFY / REMOVE（含 delete）；Cosmos DB latest-version 模式預設不發 delete、要 all-versions-and-deletes 模式才有完整事件與 delete。從 DynamoDB Streams 思維過來的 team 容易假設「delete 一定看得到」、要先確認模式。對照 <a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a></li>
<li>Knowledge card：<a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture</a> / <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a></li>
<li>回 overview：<a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> 的「忽略 Change Feed」常見陷阱</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">Cosmos DB vendor overview</a> — 本文是該頁尾 Change Feed backlog 的深度展開</li>
<li><a href="/blog/backend/09-performance-capacity/cases/asos-cosmos-db-black-friday/" data-link-title="9.C21 ASOS：Cosmos DB 在 Black Friday 撐 1.67 億請求" data-link-desc="ASOS 在 2016 Black Friday 用 Azure Cosmos DB 撐 24 小時 1.67 億請求、3500 req/sec、48ms 平均延遲">9.C21 ASOS case</a> — 高更新頻率 catalog 投影壓力的情境 anchor</li>
<li><a href="../stored-procedure-trigger/">stored-procedure-trigger</a> — partition 內同步邏輯的對照</li>
<li><a href="../partition-key-design/">partition-key-design</a> — per-partition 順序的設計來源</li>
<li><a href="/blog/backend/01-database/vendors/dynamodb/" data-link-title="DynamoDB" data-link-desc="AWS managed key-value、partition-based scaling、9000 萬 RPS sustained 實戰證據">DynamoDB vendor</a> — DynamoDB Streams 對照</li>
<li><a href="/blog/backend/knowledge-cards/change-data-capture/" data-link-title="Change Data Capture" data-link-desc="說明資料變更如何被捕捉並傳送到其他系統">Change Data Capture 卡片</a> / <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">Idempotency 卡片</a> — 概念基底</li>
<li>官方：<a href="https://learn.microsoft.com/azure/cosmos-db/change-feed">Change feed in Azure Cosmos DB</a> / <a href="https://learn.microsoft.com/azure/cosmos-db/nosql/change-feed-processor">Change feed processor</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Logical Decoding Plugins</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/logical-decoding-plugins/</link><pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/logical-decoding-plugins/</guid><description>&lt;p>PostgreSQL logical decoding plugins 的核心責任是把 WAL 中的變更轉成外部消費者可理解的事件格式。PostgreSQL 官方 logical decoding 文件說明，logical decoding 透過 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replication-slot/" data-link-title="Replication Slot" data-link-desc="說明邏輯複製如何用 slot 追蹤消費進度，並對來源端造成保留壓力">replication slot&lt;/a> 將 WAL 變更解碼成 plugin output；output plugin 決定外部看到的是 PostgreSQL protocol、JSON、測試文字或自訂格式。&lt;/p>
&lt;p>本文的判讀錨點是：plugin 選型是 CDC contract 決策。它影響 schema evolution、事件欄位、delete 表示、transaction boundary、consumer compatibility、slot lag 與故障復原。&lt;/p>
&lt;h2 id="plugin-boundary">Plugin Boundary&lt;/h2>
&lt;p>Plugin boundary 的核心責任是定義 database 變更如何離開 PostgreSQL。常見選項包含內建 &lt;code>pgoutput&lt;/code>、測試用 &lt;code>test_decoding&lt;/code>、JSON-oriented plugin，以及 Debezium connector 支援的 plugin / protocol。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Plugin / path&lt;/th>
 &lt;th>主要責任&lt;/th>
 &lt;th>適合情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>pgoutput&lt;/code>&lt;/td>
 &lt;td>PostgreSQL logical replication protocol&lt;/td>
 &lt;td>built-in logical replication、Debezium 常見路線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>test_decoding&lt;/code>&lt;/td>
 &lt;td>人類可讀測試 output&lt;/td>
 &lt;td>lab、debug、教育用途&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>wal2json&lt;/code>&lt;/td>
 &lt;td>JSON change event&lt;/td>
 &lt;td>自訂 consumer、legacy CDC&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>decoderbufs&lt;/td>
 &lt;td>Protobuf event&lt;/td>
 &lt;td>強 schema contract 的 pipeline&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Native subscription&lt;/td>
 &lt;td>DB-to-DB replication&lt;/td>
 &lt;td>PostgreSQL 之間 table replication&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>pgoutput&lt;/code> 適合標準化 CDC。它與 publication / subscription model 對齊，能保留 PostgreSQL logical replication 的主路線。&lt;/p>
&lt;p>&lt;code>test_decoding&lt;/code> 適合教學與排錯。它讓人看到 transaction 裡發生的 insert / update / delete，但它的定位是測試與理解，不應作為正式 event contract。&lt;/p>
&lt;h2 id="replication-slot-responsibility">Replication Slot Responsibility&lt;/h2>
&lt;p>Replication slot responsibility 的核心責任是保護 consumer 進度，同時管理 WAL retention。Logical slot 會讓 PostgreSQL 保留尚未被 consumer 確認的 WAL；consumer 停住時，slot lag 會轉成 disk pressure。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Signal&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;th>操作反應&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>confirmed_flush_lsn&lt;/code>&lt;/td>
 &lt;td>consumer 已確認的位置&lt;/td>
 &lt;td>用來判斷 CDC 進度&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>retained WAL size&lt;/td>
 &lt;td>slot 造成的 WAL 保留量&lt;/td>
 &lt;td>alert、調整 consumer、drop / advance&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>inactive slot&lt;/td>
 &lt;td>consumer 離線&lt;/td>
 &lt;td>檢查 connector、暫停 release&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>publication table diff&lt;/td>
 &lt;td>CDC scope 與 schema 不一致&lt;/td>
 &lt;td>review publication / table ownership&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Slot 是 production resource。每個 logical slot 都要有 owner、consumer、SLO、drop condition、backfill plan 與 alert。&lt;/p>
&lt;h2 id="event-contract">Event Contract&lt;/h2>
&lt;p>Event contract 的核心責任是讓 downstream 知道每個變更代表什麼。CDC 事件至少要說明 key、before/after image、operation、commit timestamp、transaction ordering、schema version 與 delete representation。&lt;/p></description><content:encoded><![CDATA[<p>PostgreSQL logical decoding plugins 的核心責任是把 WAL 中的變更轉成外部消費者可理解的事件格式。PostgreSQL 官方 logical decoding 文件說明，logical decoding 透過 <a href="/blog/backend/knowledge-cards/replication-slot/" data-link-title="Replication Slot" data-link-desc="說明邏輯複製如何用 slot 追蹤消費進度，並對來源端造成保留壓力">replication slot</a> 將 WAL 變更解碼成 plugin output；output plugin 決定外部看到的是 PostgreSQL protocol、JSON、測試文字或自訂格式。</p>
<p>本文的判讀錨點是：plugin 選型是 CDC contract 決策。它影響 schema evolution、事件欄位、delete 表示、transaction boundary、consumer compatibility、slot lag 與故障復原。</p>
<h2 id="plugin-boundary">Plugin Boundary</h2>
<p>Plugin boundary 的核心責任是定義 database 變更如何離開 PostgreSQL。常見選項包含內建 <code>pgoutput</code>、測試用 <code>test_decoding</code>、JSON-oriented plugin，以及 Debezium connector 支援的 plugin / protocol。</p>
<table>
  <thead>
      <tr>
          <th>Plugin / path</th>
          <th>主要責任</th>
          <th>適合情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pgoutput</code></td>
          <td>PostgreSQL logical replication protocol</td>
          <td>built-in logical replication、Debezium 常見路線</td>
      </tr>
      <tr>
          <td><code>test_decoding</code></td>
          <td>人類可讀測試 output</td>
          <td>lab、debug、教育用途</td>
      </tr>
      <tr>
          <td><code>wal2json</code></td>
          <td>JSON change event</td>
          <td>自訂 consumer、legacy CDC</td>
      </tr>
      <tr>
          <td>decoderbufs</td>
          <td>Protobuf event</td>
          <td>強 schema contract 的 pipeline</td>
      </tr>
      <tr>
          <td>Native subscription</td>
          <td>DB-to-DB replication</td>
          <td>PostgreSQL 之間 table replication</td>
      </tr>
  </tbody>
</table>
<p><code>pgoutput</code> 適合標準化 CDC。它與 publication / subscription model 對齊，能保留 PostgreSQL logical replication 的主路線。</p>
<p><code>test_decoding</code> 適合教學與排錯。它讓人看到 transaction 裡發生的 insert / update / delete，但它的定位是測試與理解，不應作為正式 event contract。</p>
<h2 id="replication-slot-responsibility">Replication Slot Responsibility</h2>
<p>Replication slot responsibility 的核心責任是保護 consumer 進度，同時管理 WAL retention。Logical slot 會讓 PostgreSQL 保留尚未被 consumer 確認的 WAL；consumer 停住時，slot lag 會轉成 disk pressure。</p>
<table>
  <thead>
      <tr>
          <th>Signal</th>
          <th>意義</th>
          <th>操作反應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>confirmed_flush_lsn</code></td>
          <td>consumer 已確認的位置</td>
          <td>用來判斷 CDC 進度</td>
      </tr>
      <tr>
          <td>retained WAL size</td>
          <td>slot 造成的 WAL 保留量</td>
          <td>alert、調整 consumer、drop / advance</td>
      </tr>
      <tr>
          <td>inactive slot</td>
          <td>consumer 離線</td>
          <td>檢查 connector、暫停 release</td>
      </tr>
      <tr>
          <td>publication table diff</td>
          <td>CDC scope 與 schema 不一致</td>
          <td>review publication / table ownership</td>
      </tr>
  </tbody>
</table>
<p>Slot 是 production resource。每個 logical slot 都要有 owner、consumer、SLO、drop condition、backfill plan 與 alert。</p>
<h2 id="event-contract">Event Contract</h2>
<p>Event contract 的核心責任是讓 downstream 知道每個變更代表什麼。CDC 事件至少要說明 key、before/after image、operation、commit timestamp、transaction ordering、schema version 與 delete representation。</p>
<table>
  <thead>
      <tr>
          <th>Contract 面向</th>
          <th>審查問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Key</td>
          <td>table 是否有 replica identity / primary key</td>
      </tr>
      <tr>
          <td>Update image</td>
          <td>是否需要 before value</td>
      </tr>
      <tr>
          <td>Delete</td>
          <td>tombstone、key-only delete、soft delete</td>
      </tr>
      <tr>
          <td>Ordering</td>
          <td>transaction order 是否要保留</td>
      </tr>
      <tr>
          <td>Schema evolution</td>
          <td>新欄位、rename、drop 欄位如何通知</td>
      </tr>
      <tr>
          <td>Backfill</td>
          <td>initial snapshot 與 streaming 如何銜接</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/knowledge-cards/replica-identity/" data-link-title="Replica Identity" data-link-desc="說明 row-level 變更事件如何帶穩定 key，讓下游能正確套用 update 與 delete">Replica identity</a> 是 CDC 的核心設定。沒有穩定 key 的 table 會讓 update / delete event 難以被 downstream 正確套用；這類 table 要先補 primary key 或明確設定 replica identity。</p>
<h2 id="connector-patterns">Connector Patterns</h2>
<p>Connector patterns 的核心責任是把 plugin output 接到實際 pipeline。Debezium、custom consumer、DB native subscription 的維運責任不同。</p>
<table>
  <thead>
      <tr>
          <th>Pattern</th>
          <th>優點</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Debezium connector</td>
          <td>成熟 snapshot + streaming workflow</td>
          <td>connector state、Kafka / offset operation</td>
      </tr>
      <tr>
          <td>Native subscription</td>
          <td>PostgreSQL 原生 DB-to-DB</td>
          <td>schema drift、DDL coordination</td>
      </tr>
      <tr>
          <td>Custom consumer</td>
          <td>可客製 event contract</td>
          <td>slot management 與 error handling 自行負責</td>
      </tr>
      <tr>
          <td>Batch export + CDC</td>
          <td>backfill 與 streaming 分開</td>
          <td>cutover LSN 與 duplication handling</td>
      </tr>
  </tbody>
</table>
<p>Connector 要定義 backfill 與 streaming 的接點。最常見的事故是 snapshot 還沒完成就開始消費、或 cutover LSN 沒有被記錄，導致 downstream 重複或漏資料。</p>
<h2 id="failure-modes">Failure Modes</h2>
<p>Failure modes 的核心責任是把 CDC 事故分成 database、connector、schema 與 downstream 四層。</p>
<table>
  <thead>
      <tr>
          <th>Failure mode</th>
          <th>判讀訊號</th>
          <th>第一反應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Slot lag growth</td>
          <td>retained WAL 持續增加</td>
          <td>暫停重型寫入、修 connector、評估 drop</td>
      </tr>
      <tr>
          <td>Schema break</td>
          <td>connector 解析失敗</td>
          <td>停止 DDL rollout、補 schema evolution</td>
      </tr>
      <tr>
          <td>Missing key</td>
          <td>update / delete 缺少可套用 key</td>
          <td>修 replica identity / key contract</td>
      </tr>
      <tr>
          <td>Duplicate event</td>
          <td>consumer 重啟或 offset 回退</td>
          <td>idempotent consumer</td>
      </tr>
      <tr>
          <td>Downstream slow</td>
          <td>Kafka / sink lag 增加</td>
          <td>擴 sink、調 batch、保護 slot</td>
      </tr>
  </tbody>
</table>
<p>Slot lag 是最高優先訊號，因為它會占用 PostgreSQL WAL storage。Runbook 要有「何時暫停 producer」、「何時 drop slot」、「如何重建 snapshot」的明確門檻。</p>
<h2 id="selection-checklist">Selection Checklist</h2>
<p>Selection checklist 的核心責任是讓 plugin 選型可審查。</p>
<ol>
<li>Downstream 需要 DB-to-DB replication、JSON event、Protobuf event 還是 connector-managed event。</li>
<li>每張 table 是否有 stable key 與 replica identity。</li>
<li>Initial snapshot 如何銜接 streaming。</li>
<li>Schema evolution 如何通知 consumer。</li>
<li>Slot lag、connector lag、sink lag 如何告警。</li>
<li>Consumer 是否 idempotent。</li>
<li>Disaster recovery 後 slot / offset 如何重建。</li>
</ol>
<p>完成這份 checklist 後，再決定 plugin 與 connector。CDC 的成功標準是 downstream 能長期維持正確資料，而不只是成功建立 slot。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>Logical decoding plugins 完成後，實作 CDC pipeline 讀 <a href="../logical-replication-debezium/">Logical Replication / Debezium</a>；slot 維運讀 <a href="../replication-slot-management/">Replication Slot Management</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 分工">Database Migration Playbook</a>。</p>
]]></content:encoded></item></channel></rss>