01

總覽與定位

資料庫已經把變更寫進日誌。CDC 要做的是:把同一份日誌變成異質下游能訂閱的事件流。

複寫基礎篇把 binlog / WAL 當成「把相同資料送到另一顆資料庫」的運輸層:協定、同步或異步、複寫延遲下會出現哪些讀異常。本篇借用同一份日誌,終點卻換了人。下游不是另一個 InnoDB,而是快取、搜尋索引、資料倉儲——它們不懂 ROW image,也不參加資料庫的法定人數。CDC 的工作是把已經發生的列變更,可靠地翻譯成這些系統聽得懂的事件。

同源思想、不同終點。複寫保證的是副本之間的一致性模型;CDC 保證的是「異質讀者最終會跟上來源表」。中間一定有延遲,也一定會重放。交付語意的定義回訊息佇列基礎;broker 分段與 ISR 回 Kafka 架構;視窗與水位回串流處理。本篇不重講那些。這裡要答的是:為什麼不能只靠 SELECT … WHERE updated_at > ?、日誌型擷取實際讀的是什麼、位點與 Snapshot 在交界處如何重疊,以及把變更餵給三類典型下游時,你在換什麼。

1 · 來源怎麼抓
Polling · Log-based
輪詢掃現況列;日誌讀 before / after image。刪除、中間狀態、掃描成本從這裡分岔。
2 · 從哪接上、接完怎麼走
Snapshot · Slot · Offset
先拍一張一致快照,再從當時的 GTID / LSN 接著串。交界會重複;沒人消費的 slot 會把磁碟吃滿。
3 · 事件要去哪
Cache · Index · Warehouse
失效、按主鍵重建、近實時合併。下游看到的是最終一致,回填靠重放同一條日誌。
座標CDC 與 Transactional Outbox 不是同一條寫入路徑

事件驅動那篇把 Outbox 寫成「領域事件與業務列同生共死」:你改寫入路徑,換來一份顯式契約。CDC 從另一側補:不改應用程式,connector 直接讀日誌,事件形狀卻跟表結構綁在一起。表加了一個內部欄位,Outbox 可以不發;CDC 預設會廣播。無侵入換來的是耦合。本篇不重做 Outbox 表設計,只在取捨出現時對照這一點。

一句話答法CDC 把資料庫變更日誌翻譯成異質下游的事件流;它與複寫共用同一份 log,但終點不是另一顆資料庫。
02

查詢輪詢的三個盲點

用 updated_at 或版本欄當游標,看起來零基礎設施。它抓的是現況快照,不是變更歷史。

最便宜的「準 CDC」長這樣:下游記住上次看到的時間戳,週期執行

SELECT * FROM orders WHERE updated_at > :cursor ORDER BY updated_at

再把游標推到本批最大值。沒有 connector、沒有 replication slot,應用程式也不用改。作為對照組,它把 CDC 真正要解決的三件事一次攤開。

刪除是隱形的
列不在了,就沒有 updated_at
  • 硬刪之後,WHERE updated_at > ? 掃不到這列。
  • 下游快取與索引會留下幽靈文件,直到你另做對帳。
  • 軟刪(deleted_at)只是把刪除假裝成更新,還是一次掃描。
中間狀態被摺疊
兩次 poll 之間只剩最後一幀
  • pending → paid → shipped 若發生在同一輪詢間隔,下游只看到 shipped。
  • 依賴「付款那一瞬」觸發的副作用(發券、風控)永遠不會被叫醒。
  • 版本欄一樣:你讀到的是最新版號,不是版號走過的路徑。
掃描成本隨表成長
游標查詢不是免費的尾巴
  • 沒有 (updated_at) 索引就是週期全表掃。
  • 有索引也要打主庫;間隔愈短,讀放大愈明顯。
  • 同一毫秒多列更新時,游標還會漏——時間戳解析度不夠。

觸發器寫 audit 表、應用層雙寫一張變更表,都是在輪詢外面再做一份日誌。那已經是「自己實作 binlog」,只是寫在 SQL 層、跟業務交易搶同一把鎖,而且刪除、大量更新、DDL 仍然容易漏。輪詢適合作偶爾對帳或冷資料回填,不適合作持續的變更來源。

陷阱把「最終列一致」當成「變更都被看見」

輪詢結束時,下游的 O-1001 可能剛好也是 shipped。面試若停在這裡,會以為輪詢夠用。問下一句:付款與出貨之間有沒有人該被通知?硬刪之後索引還在不在?表從十萬列長到一億,這條 SELECT 還打不打得起主庫?三個答案有一個是否,輪詢就不是 CDC。

一句話答法輪詢讀的是現況列:看不到硬刪、摺疊中間狀態,而且掃描成本隨表成長。CDC 要的是變更歷史,不是最新快照。
03

日誌型 CDC:binlog、WAL 與位點

來源已經為了崩潰復原把每一筆變更耐久化。CDC 是這條日誌的第二個讀者,不是第二個寫入路徑。

InnoDB 提交時,同一筆交易的列變更會進 binary log。給 CDC 用,格式必須是 ROW:每一列的 before image 與 after image。STATEMENT 只記 SQL 字串,NOW()、UUID()、多列 UPDATE 都無法還原成穩定的列事件;MIXED 在部分語句仍會退回 STATEMENT。ROW 的代價是日誌變大,換來的是「下游看到的是列,不是一句無法重放的 SQL」。

交易 COMMIT
寫入路徑只有一條
redo 先落地
──寫入──▸
binlog(ROW)
每列的 before / after
不是一句 SQL
──讀取──▸
CDC Connector
只存位點:GTID
或 LSN + slot
──投遞──▸
異質下游
快取 / 索引 / 倉儲
各自套用

binlog 是為了崩潰復原就已經存在的日誌,CDC 只是它的第二個讀者——不是第二條寫入路徑。ROW 決定你看得到 before/after,GTID 決定你斷線之後接不接得回去。

MySQL:ROW + GTID

舊式位點是 file + offset(mysql-bin.000847:194420)。Failover 換了機器、檔名重編,這個座標就失效。GTID 把一筆交易標成 source_id:transaction_id,在新主庫上仍能回答「這筆我吃過了沒有」。CDC connector 存的就是這組座標,不是業務時間。面試若把 GTID 講成「全域單調時鐘」,後面的亂序與多源匯流會對不上——它只保證同一來源上的交易身份,讓你換主之後接得回去。

Postgres:logical decoding 與 replication slot

Postgres 的寫前日誌(WAL)原本服務崩潰復原與實體複寫。Logical decoding 在其上掛一個 output plugin(pgoutput、wal2json),把堆疊變更解成列級訊息。要讓 decoding 連續,必須建 replication slot:slot 記住 confirmed_flush_LSN,主庫不能回收這個 LSN 之前的 WAL。這是 CDC 能斷線再接的原因,也是最常見的運維事故來源——connector 停了、slot 還在,WAL 會一直堆到磁碟滿。實體複寫的 replica 若落後,主庫同樣不能砍 WAL;差別是 replica 通常有人看延遲,CDC slot 常常沒人看,直到磁碟告警。

MySQL binlog Postgres WAL + slot
列級來源 ROW 格式的 before / after logical decoding 的 replica identity
接續座標 GTID(或 file + pos) LSN + slot 的 confirmed flush
未消費後果 binlog 依 expire 保留;過期會讓 connector 斷檔 slot 擋住 WAL 回收,磁碟膨脹
刪除怎麼出現 DELETE 事件帶 before image DELETE 事件帶 replica identity(通常是主鍵)

兩邊都是「日誌的第二個讀者」。複寫把同一份內容送到同構副本;CDC 把同一份內容拆成事件,送給不會說 SQL 的系統。

關鍵位點是游標,不是事件時間

GTID / LSN 回答「來源日誌讀到哪」。下游若要用事件時間做對帳或回填,應另取 source.ts_ms 或交易時間,不要拿位點當業務時鐘。位點可以跳檔、可以在 failover 後換座標系統;業務時間不行。

一句話答法日誌型 CDC 讀 ROW / logical decoding 的 before-after,用 GTID 或 LSN 接續;Postgres 的 slot 能續讀,未消費就會把 WAL 堆到磁碟滿。
04

Snapshot、增量與 Connector 位點

空的下游不能從「現在」開始聽。先拍一張一致快照,再從當時的日誌座標接著串——交界處一定會重複。

Debezium 是最常見的實作,但角色要講清楚:它是 source connector,不是 broker。部署有三種形態。Kafka Connect 獨立叢集跑一個(或一組)connector,offset 寫在 Connect 的 offsets topic,任務失敗由 Connect 重拉——這是預設。Embedded engine 把同一套讀日誌迴圈嵌進你的 JVM 行程,offset 存 JDBC / 檔案,適合沒有 Kafka 的進程內管線。Standalone 單行程則給開發與小流量。面試只說「我們用 Debezium」而不說 offset 存在哪、誰負責重啟,等於沒說故障後從哪續讀。

初始 Snapshot
先有現況,才聽得懂後面的增量
  • 在一致視圖(鎖或 MVCC 快照)下掃表,每列發一個 op=r(read)事件。
  • 記下快照當下的 GTID / LSN,Snapshot 結束後從這個座標接串流。
  • 大表會鎖很久或掃很久:這是後面 incremental snapshot 要拆掉的痛。
交界重複
快照裡的列,串流可能再送一次
  • 快照讀到 amount=80;同一區間的 binlog 又有這列的 CREATE / UPDATE。
  • 下游會看到 r 然後 c / u,主鍵相同。
  • 這不是 bug,是 at-least-once 在來源側的形狀。去重是下游的工作。

去重鍵不要用「事件到達時間」。穩定的做法是 (pk, source.lsn) 或來源提供的事件 id 做 unique insert:第二次送達是拒登保護,不是資料損壞。只有主鍵、沒有版本的 last-write-wins,在亂序時會用舊 after image 蓋掉新值——日誌型 CDC 在單一 connector 內通常保序到 partition,但 Snapshot 與串流、或兩個任務重平衡時,仍可能重疊。

Incremental snapshot

一次鎖全表做 consistent snapshot,在繁忙的主庫上過不了關。Incremental snapshot(Debezium 用 signal 表或 Kafka signal)把主鍵空間切成塊:一邊繼續聽 WAL / binlog,一邊分塊 SELECT。每一塊有自己的低高水位,與串流交錯。代價是交界重複變多——塊與塊之間、塊與串流之間都會重疊——所以 incremental 不是「比較不會重複」,是「用重複換不停寫、不長鎖」。回填某一批主鍵,也走同一條路:對 connector 發一個 ad-hoc snapshot signal,不必重跑整庫。

位點存在哪 崩潰後誰續讀 典型代價
Kafka Connect offsets topic Connect 重啟任務,從已 commit 的 offset 再拉 預設 at-least-once:未提交的那段會重放
Embedded 的 JDBC / 檔案 行程自己重開,讀自己寫的座標 沒有叢集調度;進程掛了就沒人讀,slot 開始堆
只靠記憶體 沒有 重跑只能再做一次全量 Snapshot

Connector 的 offset 與 Postgres slot、MySQL binlog retention 是三個獨立的時鐘。任一個被回收或卡住,另外兩個救不了。

互動 · 崩潰後要重放幾筆 拖 commit 間隔與崩潰位置,重放量與 offset 寫入次數反向伸縮
拖動旋鈕即時重算。
已 commit,不會再送 已投遞未 commit → 會重放 崩潰後還沒消費 被迫重跑的 Snapshot

尺上唯一會「再送一次」的是橘色那段——它的長度就是 commit 間隔欠下的債。刻痕是每一次 offset commit 的位置:刻痕愈密,橘段愈短,但寫入次數愈多。

commit 間隔的兩端代價
同一串投遞,三種去重策略留下幾列

關鍵在中間那條:(pk, LSN) unique insert 擋得住同一事件重放,卻擋不住 Snapshot 的 r 與串流的 c——它們位點不同,只有按主鍵 merge 摺得掉。

陷阱把 Snapshot 重跑當成「清乾淨重來」

下游若是 append-only 倉儲,再做一次 Snapshot 會把現況列再插入一次。回填必須帶去重或 merge-by-pk,否則歷史會加倍。快取還好——SET 是冪等的;搜尋索引的 DELETE 若沒跟著來,重跑 Snapshot 也清不掉已經從來源消失的文件。

一句話答法先做一致 Snapshot 再從當時的 GTID / LSN 接串流;交界重複用 PK + 位點去重。Incremental snapshot 用重複換不停寫。
05

上游 DDL 如何安全傳到下游

CDC 事件的形狀就是表的形狀。欄位一改,所有沒經過契約審查的消費者一起中彈。

ROW 事件本身不解釋「這張表現在有哪些欄」。Debezium 另做一份 schema history(通常是一個 compact topic):上游每一次 DDL 被捕捉後追加進去,connector 重啟時重放這份歷史,才能解後續的 ROW image。Postgres 的 logical decoding 對 DDL 更挑剔——有些變更要靠 pgoutput 的 relation 訊息,有些要另掛 event trigger。重點不是背產品開關,是:沒有 schema history,after image 只是一串沒有名字的欄位值。

下游跟得上的變更
加欄、可空、舊程式忽略未知
  • 新增可空欄或帶預設的欄:舊消費者略過,新消費者填預設。
  • 這與事件驅動那篇的 BACKWARD / FORWARD 同一條紀律,只是來源變成 DDL,不是 Registry 登錄。
  • 用 SMT 或 column.exclude.list 把內部欄位擋在 connector,而不是期望每個消費者自己濾。
一次到位的破壞
改名、改型、刪下游還在用的欄
  • amount 改成 amount_cents:舊投影找不到欄,新投影在舊事件裡也找不到。
  • 縮短 VARCHAR 或改 NOT NULL:history 能記下 DDL,解不開的是已經落地的舊訊息。
  • CDC 會忠實廣播新列形狀,它不幫你做相容性審查。

這正是 CDC 相對 Outbox 的結構性弱點。Outbox 的 payload 是領域契約:表加了 internal_score,事件可以不帶。CDC 預設把 internal_score 也推進主題,搜尋索引與倉儲開始為一個從未公開的欄位演進。過濾器能縮小爆炸半徑,無法取消「事件 ≡ 表」這條耦合。需要穩定契約、又改得了寫入路徑時,Outbox 仍是較乾淨的發佈面;CDC 適合「不能改舊系統、但必須把表的事實推出去」。

關鍵DDL 與 DML 不要當成同一條可重放流

ROW 事件可以按位點重放;DDL 往往改變「後面那些 ROW 該怎麼解」。先在下游加欄、部署能忽略未知欄位的消費者,再在來源執行 DDL,順序與 Schema Registry 的「消費者先升級」相同。只重放 binlog 而不重放 schema history,會在午夜解出錯位的欄。

一句話答法CDC 把表結構當成事件契約:加可選欄並讓消費者忽略未知是安全門;改名與改型要當新欄位演進。Outbox 用顯式契約換寫入路徑的侵入。
06

三類下游、延遲與回填

快取失效、索引重建、倉儲近實時同步,都在消費同一條變更流。它們要的保證不同,失敗面也不同。

快取失效
UPDATE / DELETE → DEL key
CDC 觸發的是失效,不是當 write-through 的唯一寫入。來源 commit 到 DEL 之間,讀到的仍是舊值——這是最終一致,不是快取擊穿的防護(那篇在 cache-failure-modes)。
搜尋索引重建
依主鍵 upsert;DELETE 必須送到
UPDATE 做成 upsert 即可;漏掉的是刪除。輪詢之所以不能餵索引,就是硬刪不會出現在 updated_at 結果裡。索引結構與排序不在本篇。
倉儲近實時
staging append + merge by PK
適合把操作庫的變化連續灌進分析側。它不取代批次對帳:Snapshot 重跑、重複投遞、時鐘回撥,都要靠 merge 而不是盲目 INSERT。

三條路徑共享同一條延遲鏈:

commit → binlog / WAL dump → connector → broker → consumer → 下游寫入

每多一跳就多一段可見的舊資料窗口。產品文案寫「即時同步」時,面試應改口成「近實時、最終一致,延遲等於這條鏈的尾延遲」。暫停 connector 或消費者,Postgres slot 的 WAL 與 Kafka consumer lag 會一起長大;追上之後狀態會收斂,中間那段窗口裡的讀,本來就不該當成來源真相。

回填與重放

「某張表從上週開始索引就不對」不是重啟 connector 能修的——offset 往前走了,錯過的 DELETE / UPDATE 不會自己回來。做法有兩層。整表不對:重做 Snapshot(或 incremental snapshot 全主鍵空間),下游用 merge-by-pk 吃 op=r。局部不對:對 connector 發指定主鍵的 snapshot signal,只回放那一區。主題本身若還留著舊事件,也可以把消費者 group 重設到較早 offset——這是訊息佇列那篇的重放,前提是 retention 還沒把那段丟掉。CDC 的優勢是來源日誌往往比主題留得更久(尤其是還在 slot / binlog expire 之內時),回填應優先問來源,而不是問已經被壓縮的下游 log。

下游 套用動作 漏事件的症狀 回填怎麼做
快取 DEL;必要時再懶載入 讀到過期值,直到 TTL 或下次命中失效 對相關 key 再 DEL;或等 TTL
搜尋索引 upsert / delete by PK 幽靈文件、或搜得到已刪的列 增量 Snapshot + 對帳刪除
倉儲 append 到 staging,按 PK merge 重複列、或維度落後數小時 重跑 Snapshot,靠 merge 而不是再 INSERT

三種下游都接受最終一致。不能接受的是「刪除蒸發」與「回填把歷史加倍」。

關鍵CDC 不是把操作庫變成事件總線的免費午餐

它省下改寫入路徑的成本,把契約、過濾、去重、DDL 紀律推到 connector 與下游。表就是 API 時,任何內部遷移都會變成跨團隊事件。這與複寫相反:複寫的讀者與寫者共享同一套 schema,沒有「異質消費者解不開」這件事。

一句話答法CDC 餵快取做失效、餵索引做 upsert/delete、餵倉儲做 merge;中間必有最終一致延遲,回填靠 Snapshot 重放而不是再掃 updated_at。
07

互動 · 輪詢摺掉了哪幾幀

同一串訂單變更,左手是 updated_at 輪詢、右手是 binlog。三支滑桿一動,兩側各自看得到幾幀、倉儲各剩幾列,全部重算。

兩列訂單交錯寫入同一張表:O-1001 一路走完狀態鏈,O-1002 走到一半被硬刪。日誌型 CDC 會為每一筆變更留下一幀 ROW 事件;查詢輪詢只在醒來那一刻讀「現況」。把輪詢間隔拖大,被摺掉的幀與延遲會一起長大;拖到最小,摺疊會歸零——但硬刪那一幀不會。

互動 · 變更幀的可見性 拖動三支滑桿,同一串變更即時重算兩側看得到什麼
拖動旋鈕即時重算。
日誌留下的幀 / 輪詢看見的幀 被摺疊(灰虛線,永遠讀不到) 刪除後蒸發 輪詢時刻

兩帶共用同一條 t 軸:上帶是日誌留下的每一幀,下帶是同一組幀在輪詢眼裡的樣子——灰虛線那些還在原位,只是永遠不會被讀到。括號上的「−N」就是那一拍摺掉幾幀。把刪除關掉,摺疊數會上升:那些被刪除截掉的寫入,本來也是會被摺的幀。

最後一列是總計。幽靈=來源已刪、輪詢倉儲還留著;從未見過=整條列的生命週期落進同一個輪詢區間,輪詢連它存在過都不知道。這兩個數字不會同時大於零。

一句話答法同一串變更,輪詢把一個週期內的多幀摺成最後一幀,而硬刪那一幀根本不會出現;把間隔調小只能救摺疊與延遲,救不了刪除。這是形式差異,不是調參問題。