總覽與定位
資料庫已經把變更寫進日誌。CDC 要做的是:把同一份日誌變成異質下游能訂閱的事件流。
複寫基礎篇把 binlog / WAL 當成「把相同資料送到另一顆資料庫」的運輸層:協定、同步或異步、複寫延遲下會出現哪些讀異常。本篇借用同一份日誌,終點卻換了人。下游不是另一個 InnoDB,而是快取、搜尋索引、資料倉儲——它們不懂 ROW image,也不參加資料庫的法定人數。CDC 的工作是把已經發生的列變更,可靠地翻譯成這些系統聽得懂的事件。
同源思想、不同終點。複寫保證的是副本之間的一致性模型;CDC 保證的是「異質讀者最終會跟上來源表」。中間一定有延遲,也一定會重放。交付語意的定義回訊息佇列基礎;broker 分段與 ISR 回 Kafka 架構;視窗與水位回串流處理。本篇不重講那些。這裡要答的是:為什麼不能只靠 SELECT … WHERE updated_at > ?、日誌型擷取實際讀的是什麼、位點與 Snapshot 在交界處如何重疊,以及把變更餵給三類典型下游時,你在換什麼。
事件驅動那篇把 Outbox 寫成「領域事件與業務列同生共死」:你改寫入路徑,換來一份顯式契約。CDC 從另一側補:不改應用程式,connector 直接讀日誌,事件形狀卻跟表結構綁在一起。表加了一個內部欄位,Outbox 可以不發;CDC 預設會廣播。無侵入換來的是耦合。本篇不重做 Outbox 表設計,只在取捨出現時對照這一點。
查詢輪詢的三個盲點
用 updated_at 或版本欄當游標,看起來零基礎設施。它抓的是現況快照,不是變更歷史。
最便宜的「準 CDC」長這樣:下游記住上次看到的時間戳,週期執行
再把游標推到本批最大值。沒有 connector、沒有 replication slot,應用程式也不用改。作為對照組,它把 CDC 真正要解決的三件事一次攤開。
- 硬刪之後,
WHERE updated_at > ?掃不到這列。 - 下游快取與索引會留下幽靈文件,直到你另做對帳。
- 軟刪(
deleted_at)只是把刪除假裝成更新,還是一次掃描。
pending → paid → shipped若發生在同一輪詢間隔,下游只看到 shipped。- 依賴「付款那一瞬」觸發的副作用(發券、風控)永遠不會被叫醒。
- 版本欄一樣:你讀到的是最新版號,不是版號走過的路徑。
- 沒有
(updated_at)索引就是週期全表掃。 - 有索引也要打主庫;間隔愈短,讀放大愈明顯。
- 同一毫秒多列更新時,游標還會漏——時間戳解析度不夠。
觸發器寫 audit 表、應用層雙寫一張變更表,都是在輪詢外面再做一份日誌。那已經是「自己實作 binlog」,只是寫在 SQL 層、跟業務交易搶同一把鎖,而且刪除、大量更新、DDL 仍然容易漏。輪詢適合作偶爾對帳或冷資料回填,不適合作持續的變更來源。
輪詢結束時,下游的 O-1001 可能剛好也是 shipped。面試若停在這裡,會以為輪詢夠用。問下一句:付款與出貨之間有沒有人該被通知?硬刪之後索引還在不在?表從十萬列長到一億,這條 SELECT 還打不打得起主庫?三個答案有一個是否,輪詢就不是 CDC。
日誌型 CDC:binlog、WAL 與位點
來源已經為了崩潰復原把每一筆變更耐久化。CDC 是這條日誌的第二個讀者,不是第二個寫入路徑。
InnoDB 提交時,同一筆交易的列變更會進 binary log。給 CDC 用,格式必須是 ROW:每一列的 before image 與 after image。STATEMENT 只記 SQL 字串,NOW()、UUID()、多列 UPDATE 都無法還原成穩定的列事件;MIXED 在部分語句仍會退回 STATEMENT。ROW 的代價是日誌變大,換來的是「下游看到的是列,不是一句無法重放的 SQL」。
redo 先落地
不是一句 SQL
或 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 後換座標系統;業務時間不行。
Snapshot、增量與 Connector 位點
空的下游不能從「現在」開始聽。先拍一張一致快照,再從當時的日誌座標接著串——交界處一定會重複。
Debezium 是最常見的實作,但角色要講清楚:它是 source connector,不是 broker。部署有三種形態。Kafka Connect 獨立叢集跑一個(或一組)connector,offset 寫在 Connect 的 offsets topic,任務失敗由 Connect 重拉——這是預設。Embedded engine 把同一套讀日誌迴圈嵌進你的 JVM 行程,offset 存 JDBC / 檔案,適合沒有 Kafka 的進程內管線。Standalone 單行程則給開發與小流量。面試只說「我們用 Debezium」而不說 offset 存在哪、誰負責重啟,等於沒說故障後從哪續讀。
- 在一致視圖(鎖或 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 的位置:刻痕愈密,橘段愈短,但寫入次數愈多。
關鍵在中間那條:(pk, LSN) unique insert 擋得住同一事件重放,卻擋不住 Snapshot 的 r 與串流的 c——它們位點不同,只有按主鍵 merge 摺得掉。
下游若是 append-only 倉儲,再做一次 Snapshot 會把現況列再插入一次。回填必須帶去重或 merge-by-pk,否則歷史會加倍。快取還好——SET 是冪等的;搜尋索引的 DELETE 若沒跟著來,重跑 Snapshot 也清不掉已經從來源消失的文件。
上游 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 適合「不能改舊系統、但必須把表的事實推出去」。
ROW 事件可以按位點重放;DDL 往往改變「後面那些 ROW 該怎麼解」。先在下游加欄、部署能忽略未知欄位的消費者,再在來源執行 DDL,順序與 Schema Registry 的「消費者先升級」相同。只重放 binlog 而不重放 schema history,會在午夜解出錯位的欄。
三類下游、延遲與回填
快取失效、索引重建、倉儲近實時同步,都在消費同一條變更流。它們要的保證不同,失敗面也不同。
updated_at 結果裡。索引結構與排序不在本篇。三條路徑共享同一條延遲鏈:
每多一跳就多一段可見的舊資料窗口。產品文案寫「即時同步」時,面試應改口成「近實時、最終一致,延遲等於這條鏈的尾延遲」。暫停 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 |
三種下游都接受最終一致。不能接受的是「刪除蒸發」與「回填把歷史加倍」。
它省下改寫入路徑的成本,把契約、過濾、去重、DDL 紀律推到 connector 與下游。表就是 API 時,任何內部遷移都會變成跨團隊事件。這與複寫相反:複寫的讀者與寫者共享同一套 schema,沒有「異質消費者解不開」這件事。
updated_at。
互動 · 輪詢摺掉了哪幾幀
同一串訂單變更,左手是 updated_at 輪詢、右手是 binlog。三支滑桿一動,兩側各自看得到幾幀、倉儲各剩幾列,全部重算。
兩列訂單交錯寫入同一張表:O-1001 一路走完狀態鏈,O-1002 走到一半被硬刪。日誌型 CDC 會為每一筆變更留下一幀 ROW 事件;查詢輪詢只在醒來那一刻讀「現況」。把輪詢間隔拖大,被摺掉的幀與延遲會一起長大;拖到最小,摺疊會歸零——但硬刪那一幀不會。
兩帶共用同一條 t 軸:上帶是日誌留下的每一幀,下帶是同一組幀在輪詢眼裡的樣子——灰虛線那些還在原位,只是永遠不會被讀到。括號上的「−N」就是那一拍摺掉幾幀。把刪除關掉,摺疊數會上升:那些被刪除截掉的寫入,本來也是會被摺的幀。
最後一列是總計。幽靈=來源已刪、輪詢倉儲還留著;從未見過=整條列的生命週期落進同一個輪詢區間,輪詢連它存在過都不知道。這兩個數字不會同時大於零。