背景
2017年7月刚刚参与工作,当时的工作内容是研发数据交换平台,ETL工具采用的是Kettle。Kettle支持表的全量推送,可以直接使用表输入组件和表输出组件即可完成。增量推送,我们采用的是触发器增量推送,时间戳增量推送,MD5/全量比对增量推送,插入更新推送,但是这三种方式都有一些缺点。
-
触发器增量推送
- 需要创建表触发器,触发事件为INSERT,UPDATE,DELETE。
- 并不是所有的数据库都支持触发器。
- 数据库分配的用户不一定有权限创建触发器。
- 可能表本身也会有其他触发器,可能会与我们需要的触发器存在冲突。
- 触发器执行效率低
- 业务表的CURD和触发器都在一个事务里执行,万一触发器出了问题,就会对业务表的操作有影响。
-
时间戳增量推送
- 要求每张表都必须要有timestamp字段,有些业务系统比较老,也不一定所有的表都会遵循数据库设计规范,不一定都会有timestamp字段。
- timestamp会受到系统时间的影响,etl服务器和数据库服务器上的时间不一定是一致的,可能会造成数据丢失。
- timestamp对精度也有要求,毫秒和纳秒,如果精度不一致,可能也会造成数据丢失,或者数据重复。
- 只能解决INSERT和UPDATE,解决不了DELETE事件。
-
MD5/全量比对增量推送
- 将表的一行行数据分别转换为MD5,与目标表的一行行数据的MD5比对,如果不一样,则更新,这样效率很低。
- MD5毕竟是hash算法,还是有一定的概率有Hash冲突,导致更新丢失。
- 不管是比较MD5还是ID,都会受顺序的影响。
-
插入更新推送
- 使用插入或更新的SQL语句来进行推送,例如MySQL的
insert ... on duplicate key update
,但不一定所有的数据库都支持这种语法,有些可能需要先Delele再insert。 - 不支持Delete
- 需要主键或者Unique Key。
- 使用插入或更新的SQL语句来进行推送,例如MySQL的
这些增量推送的方式,实际上都比较依赖业务表,可能还会对业务表有所侵入,并且同步效率并不是很高,大数据量时会效率极低,不解耦。后面就想着能不能解析数据库日志的方式,将数据写到目标表里,也就是CDC(Change Data Capture)机制,是一种基于数据库数据变更的事件型软件设计模式。
使用CDC的优势:
- 对数据库系统的影响最小,对业务表的侵入性很小,只是解析日志,消耗的是磁盘IO,网络IO
- 不需要对使用数据库的应用程序进行程序上的更改。
- 获取增量变化的延迟低。
- 事务完整性,日志一般为归档日志,仅对已提交事务的数据进行捕获。
- 不仅能够处理INSERT,UPDATE,DELETE等变更,还能处理表元数据的变化,比如字段新增,修改等。
Canal
我们第一个调研的框架是Canal,Canal是Alibaba开源的MySQL binlog 增量订阅&消费组件。其主要工作原理如下:
MySQL主备复制原理
- MySQL master 将数据变更写入二进制日志( binary log, 其中记录叫做二进制日志事件binary log events,可以通过 show binlog events 进行查看)
- MySQL slave 将 master 的 binary log events 拷贝到它的中继日志(relay log)
- MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据
canal 工作原理
- canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
- MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
- canal 解析 binary log 对象(原始为 byte 流)
- 分为canal server和canal client,canal server负责解析日志内容,canal client负责获取日志解析的内容,并提交offset
不过当时使用的版本是1.0.24,还不支持写入到kafka里,需要自己调用Canal Client的API,获取解析日志的内容,Push到Kafka里。另外,canal只支持MySQL,并且Github上项目活跃度很一般,issue堆积了太多,版本更新的频率不高。
Debezium
我们想支持更多的数据库,还有一段时间自己实现一个CDC框架,MySQL就用Canal解析,Oracle使用LogMiner解析,日志内容存放到Kafka。但我们自己手撸的CDC框架,一旦用于生产,就会出现各种各样的Bug,内存溢出也是家常便饭,后面还是公司大佬在Github上搜到Debezium框架,解了我们的燃眉之急。
Debezium架构
Debezium是构建在Kafka Connector之上的,每个DBMS都有独自的Connector,它可以对接 MySQL、PostgreSQL、SQL Server、Oracle、MongoDB 等多种SQL及NoSQL数据库,把这些数据库的数据持续以统一的格式发送到 Kafka 的主题,供下游进行实时消费。
Debezium特性
- 确保捕获所有数据更改
- 以非常低的延迟(例如,MySQL或Postgres的ms范围)生成更改事件,同时避免增加频繁轮询的CPU使用量
- 不需要更改数据模型(如"最后更新"列)
- 可以捕获删除
- 可以捕获旧记录状态和其他元数据,如事务id和引发查询(取决于数据库的功能和配置)
Debezium的实际变化数据捕获特性被修改了一系列相关的功能和选项:
- 快照:可选的,一个初始数据库的当前状态的快照可以采取如果连接器被启动并不是所有日志仍然存在(通常在数据库已经运行了一段时间和丢弃任何事务日志不再需要事务恢复或复制);
- 过滤器:可以通过白名单/黑名单过滤器配置捕获的模式、表和列集
- 屏蔽::可以屏蔽特定列中的值,例如敏感数据
- 监控: 大多数连接器都可以使用JMX进行监视
- 消息转换:例如,用于消息路由、提取新记录状态(关系连接器、MongoDB)和从事务性发件箱表中路由事件
Debezium集成方式
Kafka Connector
- 安装kafka,一般都会自带kafka connector
- 启动Kafka broker
- 编辑
plugin.path
,vim config/connect-distributed.properties
- 启动
bin/connect-distributed.sh -daemon config/connect-distributed.properties
Kafka Connector有两种启动方式:
- Standalone:Kafka Connect 仅跑在单台机器上,并且仅在这台机器上用文件来存储偏移,所以可以做到在这一台机器上重启时可以接续。
- Distributed:这个模式会把连接器任务分布到不同的节点上,并且在某个节点失效时自动把任务重新协调到其他节点上。所以,如果使用分布式模式,则不会出现单点失效的问题。
故障转移的时效取决于节点之间的「心跳」(Heartbeat)间隔。心跳指的是节点之间定期发送的,确认节点状态的消息。Kafka Connect 心跳间隔默认是 3 秒,所以通常来说数秒内,集群即可识别到错误并自动转移。
在分布式模式下,Kafka Connect 会提供三个 Kafka 主题,让连接器存储状态、偏移和配置。连接器启动时会到这几个主题中去找对应自己名字的偏移量,所以我们可以随时删除某个连接器,重启它,或者在别的地方再启动,它都能接续之前的位置继续处理。
不过需要注意的是,偏移的提交并不是实时进行,而是按间隔。默认的间隔是 10 秒(也有的是 60 秒),这意味着如果已经处理到偏移 550,但是只提交到了 500,而此时任务失败重启,任务就会从偏移 500 处开始重新处理。也就是说,使用 Kafka Connect 我们只能确保流处理中的「至少一次」(At Least Once)逻辑,而不是「正好一次」(Exactly Once)。
Debezium Server
Debezium提供了一个即时使用的应用程序,将事件流变更记录发送给Kafka,Pulsar等消息中间件,但如果想要发送给Kafka,建议还是使用Kafka Connector的方式。
修改配置文件conf/application.properties
:
properties
debezium.source.*= 用于源连接器配置; Debezium Server 的每个实例都运行一个连接器
debezium.sink.*= 用于目标系统配置
debezium.format = 用于输出序列化格式配置
debezium.transforms.* = 用于消息转换的配置
debezium.predicates.* = 用于配置消息转换谓词
案例:
properties
debezium.sink.type=kinesis
debezium.sink.kinesis.region=eu-central-1
debezium.source.connector.class=io.debezium.connector.postgresql.PostgresConnector
debezium.source.offset.storage.file.filename=data/offsets.dat
debezium.source.offset.flush.interval.ms=0
debezium.source.database.hostname=localhost
debezium.source.database.port=5432
debezium.source.database.user=postgres
debezium.source.database.password=postgres
debezium.source.database.dbname=postgres
debezium.source.topic.prefix=tutorial
debezium.source.schema.include.list=inventory
暂不支持高可用。
Debezium Embedded
Debezium还提供了Embedded方式,将Debezium集成到代码里,可以做一些复杂的数据计算工作,例如Flink CDC/ Apache Seatunnel都是使用Debezium Embedded方式。使用代码如下:
- 引入基础依赖
xml
<dependency>
<groupId>io.debezium</groupId>
<artifactId>debezium-api</artifactId>
<version>${version.debezium}</version>
</dependency>
<dependency>
<groupId>io.debezium</groupId>
<artifactId>debezium-embedded</artifactId>
<version>${version.debezium}</version>
</dependency>
- 引入Debezium Connector依赖
xml
<dependency>
<groupId>io.debezium</groupId>
<artifactId>debezium-connector-mysql</artifactId>
<version>${version.debezium}</version>
</dependency>
- java代码
java
// Define the configuration for the Debezium Engine with MySQL connector...
final Properties props = new Properties();
props.setProperty("name", "engine");
props.setProperty("connector.class", "io.debezium.connector.mysql.MySqlConnector");
props.setProperty("offset.storage", "org.apache.kafka.connect.storage.FileOffsetBackingStore");
props.setProperty("offset.storage.file.filename", "/tmp/offsets.dat");
props.setProperty("offset.flush.interval.ms", "60000");
/* begin connector properties */
props.setProperty("database.hostname", "localhost");
props.setProperty("database.port", "3306");
props.setProperty("database.user", "mysqluser");
props.setProperty("database.password", "mysqlpw");
props.setProperty("database.server.id", "85744");
props.setProperty("topic.prefix", "my-app-connector");
props.setProperty("schema.history.internal",
"io.debezium.storage.file.history.FileSchemaHistory");
props.setProperty("schema.history.internal.file.filename",
"/path/to/storage/schemahistory.dat");
// Create the engine with this configuration ...
try (DebeziumEngine<ChangeEvent<String, String>> engine = DebeziumEngine.create(Json.class)
.using(props)
.notifying(record -> {
System.out.println(record);
}).build()
) {
// Run the engine asynchronously ...
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.execute(engine);
// Do something else or wait for a signal or an event
}
// Engine is stopped when the main code is finished
Debezium Connector常用的REST API
创建作业
- url:http://:/connectors
- Request Method:
POST
- Request Body:
json
{
"name": "inventory-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "192.168.1.1",
"database.port": "3306",
"database.user": "root",
"database.password": "1357",
"database.server.id": "123",
"database.server.name": "test_shawn",
"database.include.list": "test_shawn",
"database.history.kafka.bootstrap.servers": "192.168.1.1:9092",
"database.history.kafka.topic": "schemahistory.test_shawn",
"include.schema.changes": "true"
}
}
- Response Body:
json
{
"name": "hdfs-sink-connector",
"config": {
"connector.class": "io.confluent.connect.hdfs.HdfsSinkConnector",
"tasks.max": "10",
"topics": "test-topic",
"hdfs.url": "hdfs://fakehost:9000",
"hadoop.conf.dir": "/opt/hadoop/conf",
"hadoop.home": "/opt/hadoop",
"flush.size": "100",
"rotate.interval.ms": "1000"
},
"tasks": [
{ "connector": "hdfs-sink-connector", "task": 1 },
{ "connector": "hdfs-sink-connector", "task": 2 },
{ "connector": "hdfs-sink-connector", "task": 3 }
]
}
获取所有Connectors
-
url:
http://<ip>:<port>/connectors
-
Request Method:
GET
-
Request Param:
expand
:可选,值为status或者info
-
Response Body:
如果没有expand,则返回Connector name list
json
["my-jdbc-source", "my-hdfs-sink"]
如果有expand,值为status:
json
{
"inventory-connector": {
"status": {
"name": "inventory-connector",
"connector": {
"state": "RUNNING",
"worker_id": "192.168.1.1:8083"
},
"tasks": [
{
"id": 0,
"state": "RUNNING",
"worker_id": "192.168.1.1:8083"
}
],
"type": "source"
}
}
}
如果有expand,值为info:
json
{
"inventory-connector": {
"info": {
"name": "inventory-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.user": "root",
"database.server.id": "123",
"database.hostname": "192.168.1.1",
"database.password": "1357",
"database.history.kafka.bootstrap.servers": "192.168.1.1:9092",
"database.history.kafka.topic": "schemahistory.test_shawn",
"name": "inventory-connector",
"database.server.name": "test_shawn",
"database.port": "3306",
"database.include.list": "test_shawn",
"include.schema.changes": "true"
},
"tasks": [
{
"connector": "inventory-connector",
"task": 0
}
],
"type": "source"
}
}
}
如果有expand,值为info和status,访问Url如下:http://192.168.1.1:8083/connectors?expand=info&expand=status
json
{
"inventory-connector": {
"info": {
"name": "inventory-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.user": "root",
"database.server.id": "123",
"database.hostname": "192.168.1.1",
"database.password": "1357",
"database.history.kafka.bootstrap.servers": "192.168.1.1:9092",
"database.history.kafka.topic": "schemahistory.test_shawn",
"name": "inventory-connector",
"database.server.name": "test_shawn",
"database.port": "3306",
"database.include.list": "test_shawn",
"include.schema.changes": "true"
},
"tasks": [
{
"connector": "inventory-connector",
"task": 0
}
],
"type": "source"
},
"status": {
"name": "inventory-connector",
"connector": {
"state": "RUNNING",
"worker_id": "192.168.1.1:8083"
},
"tasks": [
{
"id": 0,
"state": "RUNNING",
"worker_id": "192.168.1.1:8083"
}
],
"type": "source"
}
}
}
停止作业
- url:
http://<ip>:<port>/connectors/<connector_name>
- Request Method:
Delete
- Path Variable:
-
- connector_name: 连接器名称
- Response Body:
HTTP/1.1 204 No Content
校验配置
- url:
http://<ip>:<port>/connector-plugins/<connector_plugin_name>/config/validate
- Request Method:
PUT
- Path Variable:
-
- connector_plugin_name:连接器插件名称
- Response Body:
json
HTTP/1.1 200 OK
{
"name": "FileStreamSinkConnector",
"error_count": 1,
"groups": [
"Common"
],
"configs": [
{
"definition": {
"name": "topics",
"type": "LIST",
"required": false,
"default_value": "",
"importance": "HIGH",
"documentation": "",
"group": "Common",
"width": "LONG",
"display_name": "Topics",
"dependents": [],
"order": 4
},
"value": {
"name": "topics",
"value": "test-topic",
"recommended_values": [],
"errors": [],
"visible": true
}
},
{
"definition": {
"name": "file",
"type": "STRING",
"required": true,
"default_value": "",
"importance": "HIGH",
"documentation": "Destination filename.",
"group": null,
"width": "NONE",
"display_name": "file",
"dependents": [],
"order": -1
},
"value": {
"name": "file",
"value": null,
"recommended_values": [],
"errors": [
"Missing required configuration "file" which has no default value."
],
"visible": true
}
},
{
"definition": {
"name": "name",
"type": "STRING",
"required": true,
"default_value": "",
"importance": "HIGH",
"documentation": "Globally unique name to use for this connector.",
"group": "Common",
"width": "MEDIUM",
"display_name": "Connector name",
"dependents": [],
"order": 1
},
"value": {
"name": "name",
"value": "test",
"recommended_values": [],
"errors": [],
"visible": true
}
},
{
"definition": {
"name": "tasks.max",
"type": "INT",
"required": false,
"default_value": "1",
"importance": "HIGH",
"documentation": "Maximum number of tasks to use for this connector.",
"group": "Common",
"width": "SHORT",
"display_name": "Tasks max",
"dependents": [],
"order": 3
},
"value": {
"name": "tasks.max",
"value": "1",
"recommended_values": [],
"errors": [],
"visible": true
}
},
{
"definition": {
"name": "connector.class",
"type": "STRING",
"required": true,
"default_value": "",
"importance": "HIGH",
"documentation": "Name or alias of the class for this connector. Must be a subclass of org.apache.kafka.connect.connector.Connector. If the connector is org.apache.kafka.connect.file.FileStreamSinkConnector, you can either specify this full name, or use "FileStreamSink" or "FileStreamSinkConnector" to make the configuration a bit shorter",
"group": "Common",
"width": "LONG",
"display_name": "Connector class",
"dependents": [],
"order": 2
},
"value": {
"name": "connector.class",
"value": "org.apache.kafka.connect.file.FileStreamSinkConnector",
"recommended_values": [],
"errors": [],
"visible": true
}
}
]
}
Debezium Flink
Flink CDC本身集成了Debezium,所以使用Flink CDC,也可以直接使用Flink SQL来实现数据集成,例如:
sql
-- enable checkpoint
SET 'execution.checkpointing.interval' = '10s';
CREATE TABLE cdc_mysql_source (
id int
,name VARCHAR
,PRIMARY KEY (id) NOT ENFORCED
) WITH (
'connector' = 'mysql-cdc',
'hostname' = '127.0.0.1',
'port' = '3306',
'username' = 'root',
'password' = 'password',
'database-name' = 'database',
'table-name' = 'table'
);
-- 支持同步 insert/update/delete 事件
CREATE TABLE doris_sink (
id INT,
name STRING
)
WITH (
'connector' = 'doris',
'fenodes' = '127.0.0.1:8030',
'table.identifier' = 'database.table',
'username' = 'root',
'password' = '',
'sink.properties.format' = 'json',
'sink.properties.read_json_by_line' = 'true',
'sink.enable-delete' = 'true', -- 同步删除事件
'sink.label-prefix' = 'doris_label'
);
insert into doris_sink select id,name from cdc_mysql_source;
如果不是使用Flink CDC,而是直接使用的是Debezium + kafka + flink,则可以参考以下的方式:
sql
CREATE TABLE source
(
id int,
a int,
b int
)
WITH ( 'connector' = 'kafka',
'topic' = 'test_shawn.test_shawn.test',
'properties.bootstrap.servers' = '192.168.1.1:9092',
'properties.group.id' = 'test_shawn.test_shawn.test_copy.group.1',
'scan.startup.mode' = 'earliest-offset',
'value.format' = 'debezium-json', // 使用debezium-json格式化
'value.debezium-json.schema-include' = 'true');
CREATE TABLE test2
(
id int,
a int,
b int
)
WITH ( 'connector' = 'doris',
'fenodes' = '192.168.1.1:8030',
'table.identifier' = 'test_shawn.test2',
'username' = 'root',
'sink.label-prefix' = 'test2',
'password' = 'root');
INSERT INTO test2(id,a,b)
select id,a,b from source;
参考文献
Tutorial :: Debezium Documentation
Kafka Connect REST Interface for Confluent Platform | Confluent Documentation
数据同步工具之FlinkCDC/Canal/Debezium对比-腾讯云开发者社区-腾讯云