CDC 變更數據捕獲技術可以將源數據庫的增量變動記錄,同步到一個或多個數據目的。本文基于騰訊云 Oceanus 提供的 Flink CDC 引擎,著重介紹 Flink 在變更數據捕獲技術中的應用。
一、CDC 是什么?
CDC 是變更數據捕獲(Change Data Capture)技術的縮寫,它可以將源數據庫(Source)的增量變動記錄,同步到一個或多個數據目的(Sink)。在同步過程中,還可以對數據進行一定的處理,例如分組(GROUP BY)、多表的關聯(JOIN)等。
例如對于電商平臺,用戶的訂單會實時寫入到某個源數據庫;A 部門需要將每分鐘的實時數據簡單聚合處理后保存到 Redis 中以供查詢,B 部門需要將當天的數據暫存到 Elasticsearch 一份來做報表展示,C 部門也需要一份數據到 ClickHouse 做實時數倉。隨著時間的推移,后續 D 部門、E 部門也會有數據分析的需求,這種場景下,傳統的拷貝分發多個副本方法很不靈活,而 CDC 可以實現一份變動記錄,實時處理并投遞到多個目的地。
下圖是一個示例,通過騰訊云 Oceanus 提供的 Flink CDC 引擎,可以將某個 MySQL 的數據庫表的變動記錄,實時同步到下游的 Redis、Elasticsearch、ClickHouse 等多個接收端。這樣大家可以各自分析自己的數據集,互不影響,同時又和上游數據保持實時的同步。
二、CDC 的實現原理
通常來講,CDC 分為主動查詢和事件接收兩種技術實現模式。
對于主動查詢而言,用戶通常會在數據源表的某個字段中,保存上次更新的時間戳或版本號等信息,然后下游通過不斷的查詢和與上次的記錄做對比,來確定數據是否有變動,是否需要同步。這種方式優點是不涉及數據庫底層特性,實現比較通用;缺點是要對業務表做改造,且實時性不高,不能確保跟蹤到所有的變更記錄,且持續的頻繁查詢對數據庫的壓力較大。
事件接收模式可以通過觸發器(Trigger)或者日志(例如 Transaction log、Binary log、Write-ahead log 等)來實現。當數據源表發生變動時,會通過附加在表上的觸發器或者 binlog 等途徑,將操作記錄下來。下游可以通過數據庫底層的協議,訂閱并消費這些事件,然后對數據庫變動記錄做重放,從而實現同步。這種方式的優點是實時性高,可以精確捕捉上游的各種變動;缺點是部署數據庫的事件接收和解析器(例如 Debezium、Canal 等),有一定的學習和運維成本,對一些冷門的數據庫支持不夠。
綜合來看,事件接收模式整體在實時性、吞吐量方面占優,如果數據源是 MySQL、PostgreSQL、MongoDB 等常見的數據庫實現,建議使用 Debezium(https://debezium.io/documentation/reference/1.4/connectors/index.html)來實現變更數據的捕獲(下圖來自 Debezium 官方文檔 [https://debezium.io/documentation/reference/architecture.html])。如果使用的只有 MySQL,則還可以用 Canal (https://github.com/alibaba/canal)。
三、為什么選 Flink?
從上圖可以看到,Debezium 官方架構圖中,是通過 Kafka Streams 直接實現的 CDC 功能。而我們這里更建議使用 Flink CDC 模塊,因為 Flink 相對 Kafka Streams 而言,有如下優勢:
- Flink 的算子和 SQL 模塊更為成熟和易用
- Flink 作業可以通過調整算子并行度的方式,輕松擴展處理能力
- Flink 支持高級的狀態后端(State Backends),允許存取海量的狀態數據
- Flink 提供更多的 Source 和 Sink 等生態支持
- Flink 有更大的用戶基數和活躍的支持社群,問題更容易解決
- Flink 的開源協議允許云廠商進行全托管的深度定制,而 Kafka Streams 只能自行部署和運維
而且 Flink Table / SQL 模塊將數據庫表和變動記錄流(例如 CDC 的數據流)看做是同一事物的兩面(https://ci.apache.org/projects/flink/flink-docs-release-1.12/zh/dev/table/streaming/dynamic_tables.html),因此內部提供的 Upsert 消息結構(+I
表示新增、-U
表示記錄更新前的值、+U
表示記錄更新后的值,-D
表示刪除)可以與 Debezium 等生成的變動記錄一一對應。
四、Flink CDC 的使用方法
目前 Flink CDC 支持兩種數據源輸入方式。
(一)輸入 Debezium 等數據流進行同步
例如 MySQL -> Debezium -> Kafka -> Flink -> PostgreSQL。適用于已經部署好了 Debezium,希望暫存一部分數據到 Kafka 中以供多次消費,只需要 Flink 解析并分發到下游的場景。
在該場景下,由于 CDC 變更記錄會暫存到 Kafka 一段時間,因此可以在這期間任意啟動/重啟 Flink 作業進行消費;也可以部署多個 Flink 作業對這些數據同時處理并寫到不同的數據目的(Sink)庫表中,實現了 Source 變動與 Sink 的解耦。
用法示例
例如我們有個 MySQL 數據庫,需要實時將內容同步到 PostgreSQL 中。假設已經安裝部署好 Debezium 并開始消費 PostgreSQL 的變更日志,這些日志在持續寫入名為 YourDebeziumTopic
的 Kafka 主題中。我們可以新建一個 Flink SQL 作業,然后輸入如下 SQL 代碼(連接參數都是虛擬的,僅供參考):
CREATE TABLE `Data_Input` (
id BIGINT,
actor VARCHAR,
alias VARCHAR,
PRIMARY KEY (`id`) NOT ENFORCED
) WITH (
'connector' = 'kafka', -- 可選 'kafka','kafka-0.11'. 注意選擇對應的內置 Connector
'topic' = 'YourDebeziumTopic', -- 替換為您要消費的 Topic
'scan.startup.mode' = 'earliest-offset' -- 可以是 latest-offset / earliest-offset / specific-offsets / group-offsets 的任何一種
'properties.bootstrap.servers' = '10.0.1.2:9092', -- 替換為您的 Kafka 連接地址
'properties.group.id' = 'YourGroup', -- 必選參數, 一定要指定 Group ID
-- 定義數據格式 (Debezium JSON 格式)
'format' = 'debezium-json',
'debezium-json.schema-include' = 'false',
);
CREATE TABLE `Data_Output` (
id BIGINT,
actor VARCHAR,
alias VARCHAR,
PRIMARY KEY (`id`) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:postgresql://postgresql.example:50060/myDatabase?currentSchema=mySchema&reWriteBatchedInserts=true', -- 請替換為您的實際 PostgreSQL 連接參數
'table-name' = 'MyTable', -- 需要寫入的數據表
'username' = 'user', -- 數據庫訪問的用戶名(需要提供 INSERT 權限)
'password' = 'helloworld' -- 數據庫訪問的密碼
);
INSERT INTO `Data_Output` SELECT * FROM `Data_Input`;
如果在流計算 Oceanus 界面上,可以勾選 kafka
和 jdbc
兩個內置的 Connector:
隨后直接開始運行作業,Flink 就會源源不斷的消費 YourDebeziumTopic
這個 Kafka 主題中 Debezium 寫入的記錄,然后輸出到下游的 MySQL 數據庫中,實現了數據同步。
(二)直接對接上游數據庫進行同步
我們還可以跳過 Debezium 和 Kafka 的中轉,使用 Flink CDC Connectors(https://github.com/ververica/flink-cdc-connectors)對上游數據源的變動進行直接的訂閱處理。從內部實現上講,Flink CDC Connectors 內置了一套 Debezium 和 Kafka 組件,但這個細節對用戶屏蔽,因此用戶看到的數據鏈路如下圖所示:
用法示例
同樣的,這次我們有個 MySQL 數據庫,需要實時將內容同步到 PostgreSQL 中。但我們沒有也不想安裝 Debezium 等額外組件,那我們可以新建一個 Flink SQL 作業,然后輸入如下 SQL 代碼(連接參數都是虛擬的,僅供參考):

如果在流計算頁面,可以選擇內置的 mysql-cdc
和 jdbc
Connector:
注意需要使用 Flink CDC Connectors(https://github.com/ververica/flink-cdc-connectors)附加組件。騰訊云 Oceanus 已經自帶了 MySQL-CDC Connector,如果自行部署的話,需要下載 jar 包并將其放入 Flink 的 lib 目錄下。訪問數據庫時,請確保連接的用戶足夠權限(PostgreSQL 用戶看這里[https://debezium.io/documentation/reference/connectors/postgresql.html#postgresql-permissions],MySQL 用戶看這里[https://debezium.io/documentation/reference/connectors/mysql.html#setting-up-mysql])。
五、Flink CDC 模塊的實現
(一)Debezium JSON 格式解析類探秘
flink-json
模塊中的 org.apache.flink.formats.json.debezium.DebeziumJsonFormatFactory
是負責構造解析 Debezium JSON 格式的工廠類;同樣地,org.apache.flink.formats.json.canal.CanalJsonFormatFactory
負責 Canal JSON 格式。這些類已經內置在 Flink 1.11 的發行版中,直接可以使用,無需附加任何程序包。對于 Debezium JSON 格式而言,Flink 將具體的解析邏輯放在了 org.apache.flink.formats.json.debezium.DebeziumJsonDeserializationSchema#DebeziumJsonDeserializationSchema
類中。
上圖表示 Debezium JSON 的一條更新(Update)消息,它表示上游已將 id=123
的數據更新,且字段內包含了更新前的舊值,以及更新后的新值。
那么,Flink 是如何解析并生成對應的 Flink 消息呢?我們看下這個類的 deserialize
方法:
GenericRowData before = (GenericRowData) payload.getField(0); // 更新前的數據
GenericRowData after = (GenericRowData) payload.getField(1); // 更新后的數據
String op = payload.getField(2).toString(); // 獲取 "op" 字段的類型
if (OP_CREATE.equals(op) || OP_READ.equals(op)) { // 如果是創建 (c) 或快照讀取 (r) 消息
after.setRowKind(RowKind.INSERT); // 設置消息類型為新建 (+I)
out.collect(after); // 發送給下游
} else if (OP_UPDATE.equals(op)) { // 如果是更新 (u) 消息
before.setRowKind(RowKind.UPDATE_BEFORE); // 把更新前的數據類型設置為撤回 (-U)
after.setRowKind(RowKind.UPDATE_AFTER); // 把更新后的數據類型設置為更新 (+U)
out.collect(before); // 發送兩條數據給下游
out.collect(after);
} else if (OP_DELETE.equals(op)) { // 如果是刪除 (d) 消息
before.setRowKind(RowKind.DELETE); // 將消息類型設置為刪除 (-D) out.collect(before); // 發送給下游
} else {
... // 異常處理邏輯
}
從上述邏輯可以看出,對于每一種 Debezium 的操作碼(op
字段的類型),都可以用 Flink 的 RowKind 類型來表示。對于插入 +I
和刪除 D
,都只需要一條消息即可;而對于更新,則涉及刪除舊數據和寫入新數據,因此需要 -U
和 +U
兩條消息來對應。
特別地,在 MySQL、PostgreSQL 等支持 Upsert(原子操作的 Update or Insert)語義的數據庫中,通常前一個 -U
消息可以省略,只把后一個 +U
消息用作實際的更新操作即可,這個優化在 Flink 中也有實現。
因此可以看到,Debezium 到 Flink 消息的轉換邏輯是非常簡單和自然的,這也多虧了 Flink 先進的設計理念,很早就提出并實現了 Upsert 數據流和動態數據表之間的映射關系。
1.Flink CDC Connectors 的實現
(1)flink-connector-debezium 模塊
我們在使用 Flink CDC Connectors 時,也會好奇它究竟是如何做到的不需要安裝和部署外部服務就可以實現 CDC 的。當我們閱讀 flink-connector-mysql-cdc
的源碼時,可以看到它內部依賴了 flink-connector-debezium
模塊,而這個模塊將 Debezium Embedded(https://github.com/debezium/debezium/tree/master/debezium-embedded)嵌入到了 Connector 中。
flink-connector-debezium
的數據源實現類為 com.alibaba.ververica.cdc.debezium.DebeziumSourceFunction
,它集成了 Flink 中的 RichSourceFunction
并實現了 CheckpointedFunction
以支持快照保存狀態。
通常而言,對于 SourceFunction,我們可以從它的 run
方法入手分析。它的核心代碼如下:
this.engine = DebeziumEngine.create(Connect.class)
.using(properties) // 初始化 Debezium 所需的參數
.notifying(debeziumConsumer) // 收到批量的變更消息, 則 Debezium 會回調 DebeziumChangeConsumer 來反序列化并向下游輸出數據
.using(OffsetCommitPolicy.always())
.using(
(success, message, error) -> {
if (!success && error != null) {
this.reportError(error);
}
})
.build();
...
executor.execute(engine); // 向 Executor 提交 Debezium 線程以啟動運行
可以看到,這個 SourceFunction 使用一些預先定義的參數,初始化了一個嵌入式的 DebeziumEngine(Java 的 Runnable
),然后提交給線程池(executor)去執行。這個 Debezium 線程會批量接收 binlog 信息并回調傳入的 debeziumConsumer 以反序列化消息并交給 Flink 來處理。本類的其他方法主要負責初始化狀態和保存快照,這里略過。
這里我們再來看一下 DebeziumChangeConsumer
的實現,它的最核心的方法是 handleBatch
。當 Debezium 收到一批新的事件時,會調用這個方法來通知我們的 Connector 進行處理。這里有個 for 循環輪詢的邏輯:
for (ChangeEvent<SourceRecord, SourceRecord> event : changeEvents) { // 輪詢各個事件
SourceRecord record = event.value();
if (isHeartbeatEvent(record)) { // 如果時心跳包
// 只更新當前 offset 信息, 然后繼續(不進行實際處理)
synchronized (checkpointLock) {
debeziumOffset.setSourcePartition(record.sourcePartition());
debeziumOffset.setSourceOffset(record.sourceOffset());
}
continue;
}
deserialization.deserialize(record, debeziumCollector); // 反序列化這條消息
if (isInDbSnapshotPhase) { // 如果處于數據庫快照期, 需要阻止 Flink 檢查點(Checkpoint)生成
if (!lockHold) {
MemoryUtils.UNSAFE.monitorEnter(checkpointLock);
lockHold = true;
...
}
if (!isSnapshotRecord(record)) { // 如果已經不在數據庫快照期了, 就釋放鎖, 允許 Flink 正常生成檢查點(Checkpoint)
MemoryUtils.UNSAFE.monitorExit(checkpointLock);
isInDbSnapshotPhase = false;
...
}
}
// 更新當前 offset 信息, 并向下游 Flink 算子發送數據
emitRecordsUnderCheckpointLock(
debeziumCollector.records, record.sourcePartition(), record.sourceOffset());
}
可以看到邏輯比較簡單,只需要關注 checkpointLock
這個對象:只有持有這個對象的鎖時,才允許 Flink 進行檢查點的生成。
當作業處于數據庫快照期(即作業剛啟動時,需全量同步源數據庫的一份完整快照,此時收到的數據類型是 Debezium 的 SnapshotRecord
),則不允許 Flink 進行 Checkpoint 即檢查點的生成,以避免作業崩潰恢復后狀態不一致;同樣地,如果正在向下游算子發送數據并更新 offset 信息時,也不允許快照的進行。這些操作都是為了保證 Exacly-Once(精確一致)語義。
這里也解釋了在作業剛啟動時,如果數據庫較大(同步時間較久),Flink 剛開始的 Checkpoint 永遠失敗(超時)的原因:只有當 Flink 完整同步了全量數據后,才可以進行增量數據的處理,以及 Checkpoint 的生成。
(2)flink-connector-mysql-cdc 模塊
而對于 flink-connector-mysql-cdc
模塊而言,它主要涉及到 MySQLTableSource 的聲明和實現。
我們知道,Flink 是通過 Java 的 SPI(Service Provider Interface)機制動態加載 Connector 的,因此我們首先看這個模塊的 src/main/resources/META-INF/services/org.apache.flink.table.factories.Factory
文件,里面內容指向 com.alibaba.ververica.cdc.connectors.mysql.table.MySQLTableSourceFactory
。
打開這個工廠類,我們可以看到它定義了該 Connector 所需的參數,例如 MySQL 數據庫的用戶名、密碼、表名等信息,并負責 MySQLTableSource
實例的具體創建,而 MySQLTableSource
類對這些參數做轉換,最終會生成一個上文提到的 DebeziumSourceFunction
對象。
因此我們可以發現,這個模塊作用是一個 MySQL 參數的封裝和轉換層,最終的邏輯實現仍然是由 flink-connector-debezium
完成的。
六、MySQL CDC 常見問題&優化
由于 Flink 的 CDC 功能還比較新(1.11 版本剛開始支持,1.12 版本逐步完善),因而在應用過程中,很可能會遇到有各種問題。鑒于大多數客戶的數據源都是 MySQL,我們這里整理了客戶常見的一些問題和優化方案,希望能夠幫助到大家。
Debezium 報錯:binlog probably contains events generated with statement or mixed based replication format
當前的 Binlog 格式被設置為了 STATEMENT
或者 MIXED
, 這兩種都不被 Debezium 支持。為了使用 Flink CDC 功能,需要把 MySQL 的 binlog-format
設置為 ROW
:
SET GLOBAL binlog_format = 'ROW';
SET GLOBAL binlog_row_image = 'FULL';
如果您使用的是騰訊云的 TencentDB for MySQL,請確認下面設置:
Debezium 報錯:User does not have the 'LOCK TABLES' privilege required to obtain a consistent snapshot 或 Access denied; you need (at least one of) the SUPER, REPLICATION CLIENT privilege(s)
請對作業中指定的 MySQL 用戶賦予如下權限:SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT
,例如:
GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO '用戶名' IDENTIFIED BY '密碼';
FLUSH PRIVILEGES;
如果您使用的數據庫不允許或者不希望使用 RELOAD
進行全局鎖,則還需要授予 LOCK TABLES
權限以令 Debezium 嘗試進行表級鎖。注意,表級鎖會導致更長的數據庫鎖定時間!
如果希望徹底跳過鎖(對數據的一致性要求不高,但要求數據庫不能被鎖),則可以在 WITH 參數中設置 'debezium.snapshot.locking.mode' = 'none'
參數來跳過鎖操作。但請注意,同步過程中千萬不要隨意變更庫表的結構。
作業剛啟動期間,Flink Checkpoint 一直失敗/重啟
前文講過,Flink CDC Connector 在初始的全量快照同步階段,會屏蔽掉快照的執行,因此如果 Flink Checkpoint 需要執行的話,就會因為一直無法獲得 checkpointLock 對象的鎖而超時。
可以設置 Flink 的 execution.checkpointing.tolerable-failed-checkpoint
參數以容忍更多的 Checkpoint 失敗事件,同時可以調大 Checkpoint 周期,避免作業因 Checkpoint 失敗而一直重啟。
JDBC Sink 批量寫入時,數據會缺失幾條
如果發現數據庫中的某些數據在 CDC 同步后有缺失,請確認是否仍在使用 Flink 舊版 1.10 的 Flink SQL WITH 語法(例如 WITH 參數中的 connector.type
是舊語法[https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/table/connect.html#jdbc-connector],connector
是新語法[https://ci.apache.org/projects/flink/flink-docs-stable/dev/table/connectors/jdbc.html#how-to-create-a-jdbc-table])。
舊版語法的 Connector 在 JDBC 批量寫入 Upsert 數據(例如數據庫的更新記錄)時,并未考慮到 Upsert 與 Delete 消息之間的順序關系,因此會出現錯亂的問題,請盡快遷移到新版的 Flink SQL 語法。
異常數據造成作業持續重啟
默認情況下,如果遇到異常的數據(例如消費的 Kafka topic 在無意間混入了其他數據),Flink 會立刻崩潰重啟,然后從上個快照點(Checkpoint)重新消費。由于某條異常數據的存在,作業會永遠因為異常而重啟。可以在 WITH
參數中加入 'debezium-json.ignore-parse-errors' = 'true'
來應對這個問題。
上游 Debezium 崩潰導致寫入重復數據,結果不準
Debezium 服務端發生異常并恢復后,由于可能沒有及時記錄崩潰前的現場,可能會退化為 At least once 模式,即同樣的數據可能被發送多次,造成下游結果不準確。
為了應對這個問題,新版的 Flink 1.12 增加了一個 table.exec.source.cdc-events-duplicate
配置項(可以編輯 flink-conf.yaml
文件來配置),建議將其設置為 true
以對這些重復數據進行去重。
但是需要注意,該選項需要數據源表定義了主鍵,否則也無法進行去重操作。
七、未來展望
在 Flink 1.11 版本中,CDC 功能首次被集成到內核中。由于 Flink 1.11.0 版本有個 嚴重 Bug(https://issues.apache.org/jira/browse/FLINK-18461)造成 Upsert 數據無法寫入下游,我們建議使用 1.11.1 及以上版本。
在 1.12 版本上,Flink 還在配置項中增加了前文提到的 table.exec.source.cdc-events-duplicate 等選項以更好地支持 CDC 去重;還支持 Avro 格式的 Debezium 數據流,而不僅僅限于 JSON 了。另外,這個版本增加了對 Maxwell(https://maxwells-daemon.io/)格式的 CDC 數據流支持,
為了更好地完善 CDC 功能模塊,Flink 社區創建了 [FLINK-18822] 以追蹤關于該模塊的進展。可以從中看到,Flink 1.13 主要著力于支持更多的類型(FLINK-18758[https://issues.apache.org/jira/browse/FLINK-18758]),以及允許從 Debezium Avro、Canal 等數據流中讀取一些元數據信息等。
而在更遠的規劃中,Flink 還可能支持基于 CDC 的內存數據庫緩存,這樣我們可以在內存中動態地 JOIN 一個數據庫的副本,而不必每次都查詢源庫,這將極大地提升作業的處理能力,并降低數據庫的查詢壓力。