本工程亮点:
- 扩展并重编译flink-connector-jdbc,支持flink实时写入达梦V8数据库;
- 支持同步删除功能,源端删除数据,目标端也会实时同步删除;
- 可以获取数据写入 kafka 或 jdbc 的ETL_TIME,方便监控数据同步效率。
更多详细内容请关注公众号“大数据架构师指南”
更多高价值源码请关注我的 gitee
技术背景
随着 Oracle、SAP、英特尔等科技公司宣布**企业不会继续在俄罗斯开展正常业务**,一定程度上也给了我们一个警示,提高我们整体科技水平至关重要,从芯片上就是一个很好的例子。**国产软件替代至关重要,大国发展要懂得居安思危**!达梦国产数据库经过40年的发展,已经成功应用于中国国内金融、能源、航空、通信等数十个领域。因此要基于Flink计算引擎,面向达梦数据库做实时数据的开发就是一个非常有价值的事情,也是一个迫切的需求。
经过本人近一年不懈的钻研与调测,终于在flink-connector-jdbc(3.1.0和3.1.1版本)中开发出了支持写入DM8数据库的功能,本文将说明扩展后的flink-connector-jdbc如何高效写入国产数据库达梦(V8)。以下是达到的效果:
image-20231109171959241
相关脚本
FlinkSQL建表语句
create database watermark_db;
use watermark_db;
-- 采集MySQL
DROP TABLE IF EXISTS table_process_cdc;
CREATE TABLE IF NOT EXISTS table_process_cdc (
id bigint COMMENT '自增主键id'
, `name` string COMMENT '名称'
, age bigint COMMENT '年龄'
, gender bigint COMMENT '性别,0-男生,1-女生'
, `address` string COMMENT '住址'
, create_time timestamp(3) COMMENT '创建时间'
, update_time timestamp(3) COMMENT '更新时间'
-- 声明 update_time 是事件时间属性,并且用延迟5秒的策略来生成watermark
,WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND
,PRIMARY KEY(id) NOT ENFORCED
) COMMENT '配置表' WITH (
'connector' = 'mysql-cdc'
,'hostname' = 'hadoop105'
,'port' = '3306'
,'username' = 'root'
,'password' = 'root'
,'server-time-zone' = 'UTC+8'
,'scan.incremental.snapshot.enabled' = 'true'
,'scan.startup.mode' = 'earliest-offset'
,'database-name' = 'testdb'
,'table-name' = 'table_process_o'
);
-- kafka_dm映射表
DROP TABLE IF EXISTS ods_table_process_dm;
CREATE TABLE IF NOT EXISTS ods_table_process_dm (
id bigint COMMENT '自增主键id'
, `name` string COMMENT '名称'
, age bigint COMMENT '年龄'
, gender bigint COMMENT '性别,0-男生,1-女生'
, `address` string COMMENT '住址'
, create_time timestamp(3) COMMENT '创建时间'
, update_time timestamp(3) COMMENT '更新时间'
, kafka_time timestamp(3) COMMENT '进入Kafka的时间'
-- 声明 update_time 是事件时间属性,并且用延迟5秒的策略来生成watermark
,WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND
,PRIMARY KEY(id) NOT ENFORCED
) COMMENT '配置表' WITH (
'connector' = 'kafka'
,'topic' = 'ods_table_process_dm'
,'properties.bootstrap.servers' = 'hadoop105:9092'
,'properties.group.id' = 'my_group_id'
,'value.format' = 'debezium-json'
,'scan.startup.mode' = 'earliest-offset'
,'value.debezium-json.ignore-parse-errors' = 'true'
,'value.debezium-json.timestamp-format.standard' = 'ISO-8601'
);
-- 写入JDBC(DM8数据库)
DROP TABLE IF EXISTS table_process_sink;
CREATE TABLE IF NOT EXISTS table_process_sink (
ID bigint
, NAME string
, AGE bigint
, GENDER bigint
, `ADDRESS` string
, CREATE_TIME timestamp(3)
, UPDATE_TIME timestamp(3)
, KAFKA_TIME timestamp(3) COMMENT '进入kafka的时间'
, ETL_TIME timestamp(3) COMMENT '进入jdbc的时间'
-- 声明 UPDATE_TIME 是事件时间,并且用 延迟5秒 的策略来生成 watermark
,WATERMARK FOR UPDATE_TIME AS UPDATE_TIME - INTERVAL '5' SECOND
,PRIMARY KEY (ID) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:dm://192.168.137.103:5236',
'username' = 'DMHS',
'password' = 'Dmhs_1234',
'table-name' = 'TABLE_PROCESS',
'driver' = 'dm.jdbc.driver.DmDriver'
);
注意:
演示部分为FlinkCDC实时读取mysql数据库的binlog,写入kafka的主题,然后FlinkSQL基于flink-connector-jdbc 组件消费Kafka主题数据,写入达梦V8数据库,并支持实时同步数据库增,删,改操作。
FlinkJOB作业
set 'pipeline.name' = 'table_process_sink_达梦';
SET 'table.local-time-zone' = 'Asia/Shanghai';
set 'parallelism.default' = '1';
SET 'pipeline.operator-chaining' = 'false';
set 'execution.runtime-mode' = 'streaming';
SET 'table.exec.source.idle-timeout' = '10s';
SET 'execution.checkpointing.interval' = '5min';
SET 'execution.checkpointing.mode' = 'EXACTLY_ONCE';
set 'execution.checkpointing.tolerable-failed-checkpoints' = '2';
set 'restart-strategy' = 'fixed-delay';
set 'restart-strategy.fixed-delay.attempts' = '4';
set 'restart-strategy.fixed-delay.delay' = '30s';
set 'execution.checkpointing.unaligned' = 'true';
SET 'execution.savepoint.ignore-unclaimed-state' = 'true';
SET 'table.exec.source.cdc-events-duplicate' = 'true';
-- 采集MySQL数据写入Kafka
INSERT INTO ods_table_process_dm
SELECT
id,
`name`,
age,
gender,
`address`,
create_time,
update_time,
PROCTIME() as kafka_time
FROM table_process_cdc;
-- 消费kafka数据写入达梦数据库
INSERT INTO table_process_sink
SELECT
id,
`name`,
age,
gender,
`address`,
create_time,
update_time,
kafka_time,
PROCTIME() as etl_time
FROM ods_table_process_dm;
开发详解
flink-connector-jdbc组件分析
source/sink 是 flink最核心的部分之一,通过对其实现原理的学习,再结合源码分析,有助于加深对框架处理过程的理解,以及架构设计上的提升。
逻辑原理
如果我们对自己对接一个数据源,核心的话就是连接器 connector,比如关系型数据库就是 JDBC。
connector架构
flink-connector-jdbc 是 Apache Flink 框架提供的一个用于与关系型数据库进行连接和交互的连接器。它提供了使用 Flink 进行批处理和流处理的功能,可以方便地将关系型数据库中的数据引入 Flink 进行分析和处理,或者将 Flink 计算结果写入关系型数据库。
flink官方connector的架构如下
flink-connector-jdbc
-
MetaData 将 sql create source table 转化为实际的 CatalogTable,对应代码 RelNode;
-
Planning
创建 RelNode 的过程中使用 SPI 将所有的 source(DynamicTableSourceFactory)\sink(DynamicTableSinkFactory) 工厂动态加载,获取到 connector = kafka,然后从所有 source 工厂中过滤出名称为 kafka 并且 继承自 DynamicTableSourceFactory.class 的工厂类 KafkaDynamicTableFactory,使用 KafkaDynamicTableFactory 创建出 KafkaDynamicSource;
-
Runtime
KafkaDynamicSource 创建出 FlinkKafkaConsumer,负责flink程序实际运行。
flink-connector-jdbc可以实现以下核心功能:
- 数据源连接:通过 flink-connector-jdbc连接到各种支持JDBC标准的关系型数据库,如MySQL、PostgreSQL、Oracle等。
- 数据写入:可以将Flink的计算结果写入关系型数据库中,实现数据的持久化。
- 数据读取:可以从关系型数据库中读取数据,并将其作为Flink计算的输入数据。
- 数据格式转换:可以将关系型数据库中的数据转换为适合Flink计算的数据格式。
- 并行处理:可以根据数据源的并行度将数据进行分区和并行处理,以加速数据处理的速度。
flink-connector-jdbc为Flink提供了与关系型数据库集成的能力,可以方便地进行数据的导入、导出和处理,为开发人员提供了更强大和灵活的数据处理能力。
以下是 flink-connector-jdbc 源码组成:红色框中的代码就是我开发的哦
开发思路
-
首先添加达梦数据库的pom.xml依赖
<properties> <dm.version>8.1.2.192</dm.version> </properties> <dependencies> <!-- DM8 --> <dependency> <groupId>com.dameng</groupId> <artifactId>DmJdbcDriver18</artifactId> <version>${dm.version}</version> <scope>provided</scope> </dependency> </dependencies>经官网推荐和Maven仓库克洗
-
首先根据SPI机制添加支持DM数据库的工厂类
# resource/META-INF/services/org.apache.flink.connector.jdbc.dialect.JdbcDialectFactory org.apache.flink.connector.jdbc.databases.dm.dialect.DmDialectFactory -
创建工程类DmDialectFactory
// 在database下面创建dm,再创建 /dm/dialect // 路径如下:flink-connector-jdbc-3.1.1/flink-connector-jdbc/src/main/java/org/apache/flink/connector/jdbc/databases/dm/dialect/DmDialectFactory.java package org.apache.flink.connector.jdbc.databases.dm.dialect; import org.apache.flink.connector.jdbc.dialect.JdbcDialect; import org.apache.flink.connector.jdbc.dialect.JdbcDialectFactory; /** Factory for {@link DmDialect}. */ public class DmDialectFactory implements JdbcDialectFactory { @Override public boolean acceptsURL(String url) { return url.startsWith("jdbc:dm:"); } @Override public JdbcDialect create() { return new DmDialect(); } }JdbcDialectFactory是一个工厂类,用于创建特定数据库的JdbcDialect实例。它的主要作用是根据用户提供的JDBC连接URL,确定要连接的数据库类型,并创建对应的JdbcDialect实例。通过JdbcDialect实例,flink-connector-jdbc可以为特定类型的数据库提供更高级的功能和最佳性能。例如,JdbcDialect 可以优化生成的SQL查询,使用特定的语法和函数。它还可以检测数据库支持的特性,以避免不支持的操作。
-
创建方言类DmDialect
package org.apache.flink.connector.jdbc.databases.dm.dialect; import org.apache.flink.annotation.Internal; import org.apache.flink.connector.jdbc.converter.JdbcRowConverter; import org.apache.flink.connector.jdbc.dialect.AbstractDialect; import org.apache.flink.table.types.logical.LogicalTypeRoot; import org.apache.flink.table.types.logical.RowType; import java.util.Arrays; import java.util.EnumSet; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; /** JDBC dialect for Dm8. */ @Internal public class DmDialect extends AbstractDialect { private static final long serialVersionUID = 1L; private static final int MAX_TIMESTAMP_PRECISION = 9; private static final int MIN_TIMESTAMP_PRECISION = 1; private static final int MAX_DECIMAL_PRECISION = 38; private static final int MIN_DECIMAL_PRECISION = 1; @Override public JdbcRowConverter getRowConverter(RowType rowType) { return new DmRowConverter(rowType); } @Override public String getLimitClause(long limit) { return "FETCH FIRST " + limit + " ROWS ONLY"; } @Override public Optional<String> defaultDriverName() { return Optional.of("dm.jdbc.driver.DmDriver"); } @Override public String dialectName() { return "Dm"; } @Override public String quoteIdentifier(String identifier) { return identifier; } @Override public Optional<String> getUpsertStatement( String tableName, String[] fieldNames, String[] uniqueKeyFields) { String sourceFields = Arrays.stream(fieldNames) .map(f -> ":" + f + " " + quoteIdentifier(f)) .collect(Collectors.joining(", ")); String onClause = Arrays.stream(uniqueKeyFields) .map(f -> "t." + quoteIdentifier(f) + "=s." + quoteIdentifier(f)) .collect(Collectors.joining(" and ")); final Set<String> uniqueKeyFieldsSet = Arrays.stream(uniqueKeyFields).collect(Collectors.toSet()); String updateClause = Arrays.stream(fieldNames) .filter(f -> !uniqueKeyFieldsSet.contains(f)) .map(f -> "t." + quoteIdentifier(f) + "=s." + quoteIdentifier(f)) .collect(Collectors.joining(", ")); String insertFields = Arrays.stream(fieldNames) .map(this::quoteIdentifier) .collect(Collectors.joining(", ")); String valuesClause = Arrays.stream(fieldNames) .map(f -> "s." + quoteIdentifier(f)) .collect(Collectors.joining(", ")); // if we can't divide schema and table-name is risky to call quoteIdentifier(tableName) // for example [tbo].[sometable] is ok but [tbo.sometable] is not String mergeQuery = " MERGE INTO " + tableName + " t " + " USING (SELECT " + sourceFields + " FROM DUAL) s " + " ON (" + onClause + ") " + " WHEN MATCHED THEN UPDATE SET " + updateClause + " WHEN NOT MATCHED THEN INSERT (" + insertFields + ")" + " VALUES (" + valuesClause + ")"; return Optional.of(mergeQuery); } @Override public Optional<Range> decimalPrecisionRange() { return Optional.of(Range.of(MIN_DECIMAL_PRECISION, MAX_DECIMAL_PRECISION)); } @Override public Optional<Range> timestampPrecisionRange() { return Optional.of(Range.of(MIN_TIMESTAMP_PRECISION, MAX_TIMESTAMP_PRECISION)); } @Override public Set<LogicalTypeRoot> supportedTypes() { return EnumSet.of( LogicalTypeRoot.CHAR, LogicalTypeRoot.VARCHAR, LogicalTypeRoot.BOOLEAN, LogicalTypeRoot.VARBINARY, LogicalTypeRoot.DECIMAL, LogicalTypeRoot.TINYINT, LogicalTypeRoot.SMALLINT, LogicalTypeRoot.INTEGER, LogicalTypeRoot.BIGINT, LogicalTypeRoot.FLOAT, LogicalTypeRoot.DOUBLE, LogicalTypeRoot.DATE, LogicalTypeRoot.TIME_WITHOUT_TIME_ZONE, LogicalTypeRoot.TIMESTAMP_WITHOUT_TIME_ZONE, LogicalTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE, LogicalTypeRoot.ARRAY); } }JdbcDialect 是一个接口,用于定义与特定数据库相关的SQL语法和行为。每种不同类型的数据库可能有一些特定的SQL方言和行为,JdbcDialect提供了一种方式来处理这些差异,以确保在不同类型的数据库上执行的SQL操作正确执行,并且能够提供最佳的性能。
-
创建数据转换类DmRowConverter
/** * Runtime converter that responsible to convert between JDBC object and Flink internal object for * Dm8. */ @Internal public class DmRowConverter extends AbstractJdbcRowConverter { private static final long serialVersionUID = 1L; public DmRowConverter(RowType rowType) { super(rowType); } @Override public JdbcDeserializationConverter createInternalConverter(LogicalType type) { switch (type.getTypeRoot()) { case NULL: return val -> null; case BOOLEAN: return val -> val instanceof NUMBER ? ((NUMBER) val).booleanValue() : val; case FLOAT: return val -> val instanceof NUMBER ? ((NUMBER) val).floatValue() : val instanceof BINARY_FLOAT ? ((BINARY_FLOAT) val).floatValue() : val instanceof BigDecimal ? ((BigDecimal) val).floatValue() : val; case DOUBLE: return val -> val instanceof NUMBER ? ((NUMBER) val).doubleValue() : val instanceof BINARY_DOUBLE ? ((BINARY_DOUBLE) val).doubleValue() : val instanceof BigDecimal ? ((BigDecimal) val).doubleValue() : val; case TINYINT: return val -> val instanceof NUMBER ? ((NUMBER) val).byteValue() : val instanceof BigDecimal ? ((BigDecimal) val).byteValue() : val; case SMALLINT: return val -> val instanceof NUMBER ? ((NUMBER) val).shortValue() : val instanceof BigDecimal ? ((BigDecimal) val).shortValue() : val; case INTEGER: return val -> val instanceof NUMBER ? ((NUMBER) val).intValue() : val instanceof BigDecimal ? ((BigDecimal) val).intValue() : val; case BIGINT: return val -> val instanceof NUMBER ? ((NUMBER) val).longValue() : val instanceof BigDecimal ? ((BigDecimal) val).longValue() : val; case DECIMAL: final int precision = ((DecimalType) type).getPrecision(); final int scale = ((DecimalType) type).getScale(); return val -> val instanceof BigInteger ? DecimalData.fromBigDecimal( new BigDecimal((BigInteger) val, 0), precision, scale) : DecimalData.fromBigDecimal((BigDecimal) val, precision, scale); case CHAR: case VARCHAR: return val -> (val instanceof CHAR) ? StringData.fromString(((CHAR) val).getString()) : (val instanceof OracleClob) ? StringData.fromString(((OracleClob) val).stringValue()) : StringData.fromString((String) val); case BINARY: case VARBINARY: case RAW: return val -> val instanceof RAW ? ((RAW) val).getBytes() : val instanceof OracleBlob ? ((OracleBlob) val) .getBytes(1, (int) ((OracleBlob) val).length()) : val.toString().getBytes(); case INTERVAL_YEAR_MONTH: case INTERVAL_DAY_TIME: return val -> val instanceof NUMBER ? ((NUMBER) val).intValue() : val; case DATE: return val -> val instanceof DATE ? (int) (((DATE) val).dateValue().toLocalDate().toEpochDay()) : val instanceof Timestamp ? (int) (((Timestamp) val) .toLocalDateTime() .toLocalDate() .toEpochDay()) : (int) (((Date) val).toLocalDate().toEpochDay()); case TIME_WITHOUT_TIME_ZONE: return val -> val instanceof DATE ? (int) (((DATE) val).timeValue().toLocalTime().toNanoOfDay() / 1_000_000L) : (int) (((Time) val).toLocalTime().toNanoOfDay() / 1_000_000L); case TIMESTAMP_WITHOUT_TIME_ZONE: return val -> val instanceof TIMESTAMP ? TimestampData.fromTimestamp(((TIMESTAMP) val).timestampValue()) : TimestampData.fromTimestamp((Timestamp) val); case TIMESTAMP_WITH_TIME_ZONE: return val -> { if (val instanceof TIMESTAMPTZ) { final TIMESTAMPTZ ts = (TIMESTAMPTZ) val; final ZonedDateTime zdt = ZonedDateTime.ofInstant( ts.timestampValue().toInstant(), ts.getTimeZone().toZoneId()); return TimestampData.fromLocalDateTime(zdt.toLocalDateTime()); } else { return TimestampData.fromTimestamp((Timestamp) val); } }; case ARRAY: case ROW: case MAP: case MULTISET: default: return super.createInternalConverter(type); } } @Override public String converterName() { return "Dm"; } }createInternalConverter 是一个方法,用于创建将 JDBC ResultSet中的数据转换为Flink的内部数据结构的转换器。这个方法通常在JDBCInputFormat中被调用。在 Flink中,使用JDBCInputFormat从关系型数据库中读取数据时,它会将JDBC的ResultSet对象作为输入,然后通过 createInternalConverter方法将 ResultSet中的每一行数据转换为Flink的内部数据结构(例如Tuple或Row),以便后续的处理和计算。
源码打包
使用idea工具的maven打包并在项目文件夹打开。
target/flink-connector-jdbc-3.1.1.jar