前言
距离前一次多表同步文章到现在已经两年啦,今天更新一下方法(相对而言代码量少一点,理解起来也容易一些)
方案思路
中心思想,自定义序列化,拿到表名(自定义sink组装语句用)、主键。然后自定义msyql sink,根据表名和类型(delete还是upsert)组装不同的statement,然后批量写入,大致就是这个样子,再详细说就暴露我的代码水平了
版本
flink版本:1.20.1
flink cdc版本:3.4.0
注意:需先在sink库创建好相应的表
不多说,直接贴代码了
import com.alibaba.fastjson2.JSON;
import config.CDCConfig;
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.cdc.connectors.mysql.source.MySqlSource;
import org.apache.flink.cdc.connectors.mysql.table.StartupOptions;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.configuration.RestOptions;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import sink.MysqlJdbcSink;
/**
* @author csdn
*/
public class MysqlSyncMysql {
private static final CDCConfig config = CDCConfig.load("config.yml");
public static void main(String[] args) throws Exception {
Configuration configuration = new Configuration();
configuration.setString(RestOptions.BIND_PORT, "8081");
// 启用空闲空间监测
configuration.setInteger("table.exec.source.idle-timeout", 5);
StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironmentWithWebUI(configuration);
// StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setRuntimeMode(RuntimeExecutionMode.STREAMING);
env.enableCheckpointing(10000);
// checkpoint超时时间
env.getCheckpointConfig().setCheckpointTimeout(120000);
// checkpoint最小间隔
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(5000);
// 同时只允许一个checkpoint
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
StartupOptions startupOptions = config.getSource().getStartupOptions() == null ? StartupOptions.initial() : StartupOptions.specificOffset(config.getSource().getStartupOptions());
// 1. 创建 MySQL CDC Source,监听整个数据库
MySqlSource mySqlSource = MySqlSource.<String>builder()
.hostname(config.getSource().getHost())
.port(config.getSource().getPort())
.databaseList(config.getSource().getDatabase())
.tableList(config.getTables())
.username(config.getSource().getUsername())
.password(config.getSource().getPassword())
.deserializer(new DynamicDeserialization())
.build();
DataStreamSource<String> source = env
.fromSource(mySqlSource,WatermarkStrategy.noWatermarks(),"MySQL Source")
.setParallelism(2);
source
.keyBy(jsonStr -> JSON.parseObject(jsonStr).getString("table"))
.process(new MysqlJdbcSink(
config.getCdc().getBatchSize(),
config.getCdc().getMaxDelay(),
String.format("jdbc:mysql://%s:%d/%s", config.getSink().getHost(), config.getSink().getPort(), config.getSink().getDatabase()),
config.getSink().getUsername(),
config.getSink().getPassword()
))
// 防止多个表占用多个slot
.setParallelism(2)
.uid("cdc-jdbc-sink");
env.execute("CDC to JDBC Job");
}
}
自定义sink
package sink;
import com.alibaba.fastjson.JSONObject;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.Data;
import org.apache.flink.cdc.connectors.shaded.com.google.common.collect.Maps;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.connector.jdbc.JdbcConnectionOptions;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MysqlJdbcSink extends KeyedProcessFunction<String, String, Void> {
private final int batchSize;
private final long maxDelay;
private transient Connection connection;
private final JdbcConnectionOptions connectionOptions;
private transient HikariDataSource dataSource;
private Map<String, Map<String, TableData>> batchBuffer = Maps.newConcurrentMap();
private Map<String, Long> timerState = Maps.newConcurrentMap();
public MysqlJdbcSink(int batchSize, long maxDelay, String jdbcUrl, String username, String password) {
this.batchSize = batchSize;
this.maxDelay = maxDelay;
this.connectionOptions = new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl(jdbcUrl)
.withDriverName("com.mysql.jdbc.Driver")
.withUsername(username)
.withPassword(password)
.build();
}
@Override
public void open(Configuration parameters) throws Exception {
// 初始化数据库连接
connection = createConnection();
}
@Override
public void processElement(String value, Context ctx, Collector<Void> out) throws Exception {
writeRecord(value, ctx);
}
public synchronized void writeRecord(String record, Context ctx) throws IOException {
try {
TableData tableData = TableData.fromJson(record);
String tableName = tableData.getTableName();
String uniqueKeyField = tableData.getUniqueKeyField();
String uniqueKeyValue = String.valueOf(tableData.getData().get(uniqueKeyField));
// 添加数据到缓存
Map<String, TableData> buffer = batchBuffer.get(tableName);
if (buffer == null) {
buffer = Maps.newConcurrentMap();
// 第一条数据时设置定时器
long timer = ctx.timerService().currentProcessingTime() + maxDelay;
ctx.timerService().registerProcessingTimeTimer(timer);
timerState.put(tableName, timer);
}
buffer.put(uniqueKeyValue, tableData);
batchBuffer.put(tableName, buffer);
// 达到批次大小时触发flush
if (buffer.size() >= batchSize) {
log.info(tableName + " - 批次达到阈值,触发flush");
flushWithRetry(tableName, "BATCH_SIZE");
// 清理定时器
Long timer = timerState.get(tableName);
if (timer != null) {
ctx.timerService().deleteProcessingTimeTimer(timer);
timerState.remove(tableName);
}
}
} catch (Exception e) {
throw new IOException("Writing records to JDBC failed.", e);
}
}
private synchronized void flushWithRetry(String tableName, String triggerType) throws Exception {
log.info("开始flush操作: {} , 触发类型: {}", tableName, triggerType);
int maxRetries = 5;
int retryCount = 0;
Exception lastException = null;
while (true) {
try {
Map<String, TableData> buffer = batchBuffer.get(tableName);
long startTime = System.currentTimeMillis();
flush(tableName);
long costTime = System.currentTimeMillis() - startTime;
log.info("{} - {} flush完成,处理{}条数据,耗时{}ms", tableName, triggerType, buffer.size(), costTime);
batchBuffer.remove(tableName);
break;
} catch (SQLException e) {
lastException = e;
retryCount++;
if (retryCount >= maxRetries) {
break;
}
long sleepTime = Math.min(1000L * (1L << retryCount), 10000L);
log.warn("{} - 第{}次重试,等待{}ms", tableName, retryCount, sleepTime);
Thread.sleep(sleepTime);
checkAndRenewConnection();
}
}
if (lastException != null) {
log.error("{} - {} flush失败,重试{}次后放弃", tableName, triggerType, maxRetries);
throw lastException;
}
log.info("flush操作完成: {}", tableName);
}
private void flush(String tableName) throws Exception {
checkAndRenewConnection();
Map<String, TableData> buffer = batchBuffer.get(tableName);
if (buffer == null || buffer.isEmpty()) {
return;
}
Map<String, PreparedStatement> stmtCache = new HashMap<>();
try {
connection.setAutoCommit(false);
for (TableData record : buffer.values()) {
// 为每条记录生成对应的SQL语句(insert/upsert/delete)
String sql = record.generateSql();
// 复用相同SQL的PreparedStatement,避免重复创建
// computeIfAbsent: 如果SQL不存在,则创建新的PreparedStatement;如果存在,则直接返回
PreparedStatement stmt = stmtCache.computeIfAbsent(sql, k -> {
try {
return connection.prepareStatement(k);
} catch (SQLException e) {
throw new RuntimeException(e);
}
});
setParameters(stmt, record);
stmt.addBatch();
}
// 执行批处理
for (PreparedStatement stmt : stmtCache.values()) {
stmt.executeBatch();
}
connection.commit();
} catch (Exception e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(true);
for (PreparedStatement stmt : stmtCache.values()) {
stmt.close();
}
}
}
// 添加重连机制和连接检查
private void checkAndRenewConnection() throws SQLException {
if (connection == null || connection.isClosed()) {
connection = createConnection();
}
}
private Connection createConnection() throws SQLException {
if (dataSource == null) {
HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setJdbcUrl(connectionOptions.getDbURL());
// 正确处理 Optional 类型的用户名和密码
hikariConfig.setUsername(connectionOptions.getUsername().orElse(null));
hikariConfig.setPassword(connectionOptions.getPassword().orElse(null));
// 连接池配置
hikariConfig.setMinimumIdle(1);
hikariConfig.setMaximumPoolSize(10);
// 连接超时设置
hikariConfig.setConnectionTimeout(30000); // 等待连接池分配连接的最大时长
hikariConfig.setIdleTimeout(600000); // 空闲连接存活最大时间,默认600000(10分钟)
hikariConfig.setMaxLifetime(1800000); // 连接最大生命周期,默认1800000(30分钟)
dataSource = new HikariDataSource(hikariConfig);
}
return dataSource.getConnection();
}
@Override
public void close() throws SQLException {
if(connection != null) {
connection.close();
}
if (dataSource != null) {
dataSource.close();
}
}
@Override
public synchronized void onTimer(long timestamp, OnTimerContext ctx, Collector<Void> out) throws Exception {
String tableName = ctx.getCurrentKey();
log.info("{} - 定时器触发flush,时间: {}", tableName, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
if(batchBuffer.containsKey(tableName) && !batchBuffer.get(tableName).isEmpty()) {
// 定时器触发的flush也会遵循锁机制,如果获取不到锁会跳过
flushWithRetry(tableName, "TIMER");
timerState.remove(tableName);
}
}
private void setParameters(PreparedStatement stmt, TableData record) throws SQLException {
String operation = record.getOperation();
String primaryKeyField = record.getUniqueKeyField();
// 与 generateSql 方法中的删除判断逻辑保持一致
if ("d".equalsIgnoreCase(operation) || "-d".equalsIgnoreCase(operation) || "-u".equalsIgnoreCase(operation) || "delete".equalsIgnoreCase(operation)) {
stmt.setObject(1, record.getData().get(primaryKeyField));
} else {
// insert和update使用相同的参数设置方式
int paramIndex = 1;
for (Object value : record.getData().values()) {
stmt.setObject(paramIndex++, value);
}
}
}
/**
* 定义一个数据结构来封装CDC数据
*/
@Data
public static class TableData {
private String tableName;
private String operation;
private String uniqueKeyField;
private Map<String, Object> data;
/**
* 从JSON字符串创建TableData的工厂方法
*/
public static TableData fromJson(String jsonStr) {
JSONObject json = JSONObject.parseObject(jsonStr);
TableData tableData = new TableData();
tableData.setTableName(json.getString("table"));
tableData.setOperation(json.getString("op"));
tableData.setUniqueKeyField(json.getString("primaryKeyField"));
tableData.setData(json.getJSONObject("data"));
return tableData;
}
/**
* 生成SQL语句
*/
public String generateSql() {
if ("d".equalsIgnoreCase(operation) || "-d".equalsIgnoreCase(operation) || "-u".equalsIgnoreCase(operation) || "delete".equalsIgnoreCase(operation)) {
if (uniqueKeyField == null) {
throw new IllegalStateException("Unique key is required for delete operation");
}
return generateDeleteSql();
} else {
// 对于insert和update,根据uniqueKeyField决定使用insert还是upsert
return uniqueKeyField == null ? generateInsertSql() : generateUpsertSql();
}
}
private String generateInsertSql() {
String fields = data.keySet().stream()
.map(field -> "`" + field + "`")
.collect(Collectors.joining(","));
String placeholders = String.join(",", Collections.nCopies(data.size(), "?"));
return String.format(
"INSERT INTO `%s` (%s) VALUES (%s)",
tableName,
fields,
placeholders
);
}
private String generateUpsertSql() {
String fields = data.keySet().stream()
.map(field -> "`" + field + "`")
.collect(Collectors.joining(","));
String placeholders = String.join(",", Collections.nCopies(data.size(), "?"));
String updateClause = data.keySet().stream()
.filter(field -> !field.equals(uniqueKeyField))
.map(field -> "`" + field + "`=VALUES(`" + field + "`)")
.collect(Collectors.joining(","));
return String.format(
"INSERT INTO `%s` (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
tableName,
fields,
placeholders,
updateClause
);
}
private String generateDeleteSql() {
return String.format("DELETE FROM %s WHERE %s=?", tableName, uniqueKeyField);
}
}
}
自定义序列化
import com.alibaba.fastjson2.JSONObject;
import org.apache.flink.api.common.typeinfo.TypeInformation;
import org.apache.flink.cdc.connectors.shaded.org.apache.kafka.connect.data.Field;
import org.apache.flink.cdc.connectors.shaded.org.apache.kafka.connect.data.Schema;
import org.apache.flink.cdc.connectors.shaded.org.apache.kafka.connect.data.Struct;
import org.apache.flink.cdc.connectors.shaded.org.apache.kafka.connect.source.SourceRecord;
import org.apache.flink.cdc.debezium.DebeziumDeserializationSchema;
import org.apache.flink.util.Collector;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
* 动态解析 MySQL CDC 数据,提取表名和字段
* @author csdn
*/
public class DynamicDeserialization implements DebeziumDeserializationSchema<String> {
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Override
public void deserialize(SourceRecord record, Collector<String> collector) throws Exception {
Struct value = (Struct) record.value();
if (value == null) {
return;
}
Struct source = value.getStruct("source");
String tableName = source.getString("table");
Struct after = value.getStruct("after");
Struct before = value.getStruct("before");
// 获取操作类型(c = create, u = update, d = delete)
String op = value.getString("op");
// 获取主键字段
String primaryKeyField = getPrimaryKey(record);
// 没有主键的操作跳过
if (primaryKeyField == null) {
System.out.println("Table " + tableName + " has no primary key, skipping processing.");
return;
}
// 删除用 `before`,其余用 `after`
Struct dataStruct = (op.equals("d")) ? before : after;
// 构造 JSON 数据
JSONObject json = new JSONObject();
json.put("table", tableName);
json.put("op", op);
json.put("primaryKeyField", primaryKeyField);
if (dataStruct == null) {
return;
}
// 获取字段列表
Schema schema = dataStruct.schema();
List<String> fieldNames = schema.fields().stream().map(Field::name).collect(Collectors.toList());
JSONObject data = new JSONObject();
for (String fieldName : fieldNames) {
Object fieldValue = dataStruct.get(fieldName);
// 处理 DATETIME 类型,把时间戳转换成 "yyyy-MM-dd HH:mm:ss"
if (fieldValue instanceof Long && fieldName.toLowerCase().contains("time")) {
fieldValue = DATE_FORMAT.format(new Date((Long) fieldValue));
}
data.put(fieldName, fieldValue);
}
json.put("data", data);
collector.collect(json.toString());
}
@Override
public TypeInformation<String> getProducedType() {
return TypeInformation.of(String.class);
}
/**
* 从 SourceRecord.key() 获取主键字段名
*/
private String getPrimaryKey(SourceRecord record) {
Object key = record.key();
if (key instanceof Struct) {
Struct keyStruct = (Struct) key;
Schema keySchema = keyStruct.schema();
// 获取 keySchema 下所有字段(主键字段)
List<Field> keyFields = keySchema.fields();
if (!keyFields.isEmpty()) {
// 假设主键只有一个,返回第一个字段名
return keyFields.get(0).name();
}
}
return null;
}
}
配置类
package config;
import lombok.Data;
import org.apache.flink.shaded.jackson2.org.yaml.snakeyaml.Yaml;
import java.io.InputStream;
@Data
public class CDCConfig {
private SourceConfig source;
private SinkConfig sink;
private PoolConfig pool;
private CdcConfig cdc;
private String tables;
@Data
public static class SourceConfig {
private String host;
private int port;
private String database;
private String username;
private String password;
private String startupOptions;
}
@Data
public static class SinkConfig {
private String host;
private int port;
private String database;
private String username;
private String password;
}
@Data
public static class PoolConfig {
private int minimumIdle = 1;
private int maximumPoolSize = 10;
private long connectionTimeout = 30000;
private long idleTimeout = 600000;
private long maxLifetime = 1800000;
private long validationTimeout = 5000;
}
@Data
public static class CdcConfig {
private int batchSize = 100;
private long maxDelay = 1000;
private int parallelism = 2;
private int maxRetries = 3;
}
public static CDCConfig load(String configPath) {
try (InputStream input = CDCConfig.class.getClassLoader().getResourceAsStream(configPath)) {
Yaml yaml = new Yaml();
return yaml.loadAs(input, CDCConfig.class);
} catch (Exception e) {
throw new RuntimeException("Failed to load configuration: " + configPath, e);
}
}
}
config.yml
source:
host: localhost
port: 33306
database: lidy
username: root
password: Ldy@
sink:
host: localhost
port: 33306
database: flinktest
username: root
password: Ldy@
tables: lidy.policy_info,lidy.policy_info_key_words
pool:
minimumIdle: 1
maximumPoolSize: 10
connectionTimeout: 30000
idleTimeout: 600000
maxLifetime: 1800000
validationTimeout: 5000
cdc:
batchSize: 100
maxDelay: 1000
parallelism: 2
maxRetries: 3
&spm=1001.2101.3001.5002&articleId=149908361&d=1&t=3&u=7e226779c7cd42fbaf1d4abe7042807d)
2458

被折叠的 条评论
为什么被折叠?



