Flink CDC多表同步(mysql到mysql)

前言

距离前一次多表同步文章到现在已经两年啦,今天更新一下方法(相对而言代码量少一点,理解起来也容易一些)

方案思路

中心思想,自定义序列化,拿到表名(自定义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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值