Flink Sink JDBC 源码分析

说明:以下所有都基于Flink1.11版本  代码都精简过了

完整例子 

public class MysqlSinkExample {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        DataStreamSource<String> source = env.fromElements("a", "b", "c", "d");

        //执行参数 
        JdbcExecutionOptions executionOptions = JdbcExecutionOptions.builder().withBatchIntervalMs(500L).withBatchSize(10000).build();
        //数据库连接参数
        JdbcConnectionOptions connectionOptions = (new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()).withUrl("jdbcurl").withDriverName("db.driver").withUsername("db.username").withPassword("monitor.db.password").build();
        //sql
        final String sql = "INSERT INTO letter (name) VALUES (?)";
        //输出
        source.addSink(JdbcSink.sink(
                sql,
                (st, r) -> {
                    st.setString(1, r);
                },
                executionOptions,
                connectionOptions
        ));

        env.execute("sink mysql");
    }
}

上面需求就是将元素 "a" "b" "c"  "d" 写入 letter 表!可以看到我们有配置

  • JdbcExecutionOptions  执行参数

    • batchIntervalMs :每次批量写入最小间隔时间

    • batchSize :批量写入的数据大小

    • maxRetries :插入发生异常重试次数  注意:只支持  SQLException 异常及其子类异常重试

  • JdbcConnectionOptions 连接参数配置

  • sql  写入的sql语句

  • 里面还有一个 labmbda 函数实现它  本身是一个消费型labmbda函数

    • PreparedStatement :第一个参数

    • 第二参数是元素 如代码中的a b c ...

       

      写入数据成功时候,会不会想一些其他的问题 ?

      如何实现批量写入的?

      批量写入是不是会产生数据延迟?

      我想转实时写入怎么配置?

      数据会丢失吗?
       

      源码

      看源码之前看一张核心类关联图,请记住这三个核心类,后面都穿插着他们身影。

工厂类 JdbcSink

我们是通过 JdbcSink.sink(...) 构造一个 SinkFunction 提供给Flink输出数据!

--  org.apache.flink.connector.jdbc.JdbcSink

public class JdbcSink {
 public static <T> SinkFunction<T> sink(
  String sql,
  JdbcStatementBuilder<T> statementBuilder,
  JdbcExecutionOptions executionOptions,
  JdbcConnectionOptions connectionOptions) {
  return new GenericJdbcSinkFunction<>(  //  todo GenericJdbcSinkFunction
   new JdbcBatchingOutputFormat<>(    //   todo JdbcBatchingOutputFormat  批量写出处理类
    new SimpleJdbcConnectionProvider(connectionOptions),   //todo Jdbc连接包装类
    executionOptions, //todo 执行参数类 如重试次数 最大等待时间 最大等待条数
    context -> {
     Preconditions.checkState(
      !context.getExecutionConfig().isObjectReuseEnabled(),
      "objects can not be reused with JDBC sink function");
     return JdbcBatchStatementExecutor.simple(   //todo  返回的就是这个 SimpleBatchStatementExecutor 这里封装了 最终写入逻辑
      sql,
      statementBuilder,
      Function.identity());
    },
    JdbcBatchingOutputFormat.RecordExtractor.identity()
   ));
 }

根据此图结合看一下 核心类关系图! 

这里我们不用去理解具体每个类作用,但是必须知道通过工厂构造出一个 GenericJdbcSinkFunction  !

数据处理:GenericJdbcSinkFunction

Flink官方提供了 RichSinkFunction 供我们完成数据输出!它有两个接口供下游处理!open 函数初始化方法 在实际处理数据之前仅会调用一次 invoke 每进来一条数据都会调用一次

open(Configuration parameters) 
    
invoke(T value, Context context)

我们再来看看 GenericJdbcSinkFunction 实现 RichSinkFunction 之后具体干了什么?

public class GenericJdbcSinkFunction<T> extends RichSinkFunction<T> {
    private final AbstractJdbcOutputFormat<T > outputFormat;

 public GenericJdbcSinkFunction(@Nonnull AbstractJdbcOutputFormat<T> outputFormat) {
  this.outputFormat = Preconditions.checkNotNull(outputFormat);
 }
    
 @Override
 public void open(Configuration parameters) throws Exception {
  super.open(parameters);
  RuntimeContext ctx = getRuntimeContext();
  outputFormat.setRuntimeContext(ctx);
  outputFormat.open(ctx.getIndexOfThisSubtask(), ctx.getNumberOfParallelSubtasks());
 }

 @Override
 public void invoke(T value, Context context) throws IOException {
  outputFormat.writeRecord(value);
 }
}

简化代码发现:open执行时候会去调用 outputFormat.open(...) invoke 也只是调用了 outputFormat.writeRecord(value)

那么outputFormat是具体那个类实现? 回到构造工厂可以看到 里面构造了  JdbcBatchingOutputFormat

这个时候可以理解: AbstractJdbcOutputFormatoutputFormat = JdbcBatchingOutputFormatoutputFormat

初始化:JdbcBatchingOutputFormat#Open
public class JdbcBatchingOutputFormat extend AbstractJdbcOutputFormat{
 private transient JdbcExec jdbcStatementExecutor;
    
 @Override
 public void open(int taskNumber, int numTasks) throws IOException {
        //初始化jdbc连接
  super.open(taskNumber, numTasks);
        
  // todo 创建执行器 SimpleBatchStatementExecutor
  jdbcStatementExecutor = createAndOpenStatementExecutor(statementExecutorFactory);
  
        // todo 如果 配置输出到jdbc最小间隔不等于0 且最小条数不是1 就创建一个固定定时线程池
  if (executionOptions.getBatchIntervalMs() != 0 && executionOptions.getBatchSize() != 1) {
   this.scheduler = Executors.newScheduledThreadPool(
    1,
    new ExecutorThreadFactory("jdbc-upsert-output-format"));
   this.scheduledFuture = this.scheduler.scheduleWithFixedDelay(
    () -> {
     synchronized (JdbcBatchingOutputFormat.this) {
      if (!closed) {
       try {
                                // todo 批量缓存的数据都写到jdbc
        flush(); 
       } catch (Exception e) {
        flushException = e;
       }
      }
     }
    },
    executionOptions.getBatchIntervalMs(),
    executionOptions.getBatchIntervalMs(),
    TimeUnit.MILLISECONDS);
  }
 }
    
}

其实这里就干了三件事情: 第一件事:super.open(taskNumber, numTasks)就是调用了抽象父类中 AbstractJdbcOutputFormat的open方法

public abstract class AbstractJdbcOutputFormat{
    protected transient Connection connection;
 protected final JdbcConnectionProvider connectionProvider;
    
 @Override
 public void open(int taskNumber, int numTasks) throws IOException {
  try {
   establishConnection();
  } catch (Exception e) {
   throw new IOException("unable to open JDBC writer", e);
  }
 }
    
    protected void establishConnection() throws Exception {
        //创建jdbc连接
  connection = connectionProvider.getConnection();
 }
}

而这个JdbcConnectionProvider这个类就是在在构造JdbcBatchingOutputFormat构造方法传入的SimpleJdbcConnectionProvider

SimpleJdbcConnectionProvider#getConnection 可以看到就是创建了Connection

public class SimpleJdbcConnectionProvider{
 private final JdbcConnectionOptions jdbcOptions;
 private transient volatile Connection connection;
    
    @Override
 public Connection  getConnection() throws SQLException, ClassNotFoundException {
  if (connection == null) {
   synchronized (this) {
    if (connection == null) {
     Class.forName(jdbcOptions.getDriverName());
     if (jdbcOptions.getUsername().isPresent()) {
      connection = DriverManager.getConnection(jdbcOptions.getDbURL(), jdbcOptions.getUsername().get(), jdbcOptions.getPassword().orElse(null));
     } else {
      connection = DriverManager.getConnection(jdbcOptions.getDbURL());
     }
    }
   }
  }
  return connection;
 }
}

第二件事:createAndOpenStatementExecutor(statementExecutorFactory)  创建执行器 SimpleBatchStatementExecutor

然后调用 SimpleBatchStatementExecutor#prepareStatements(connection)  初始化了 PreparedStatement st

第二件事:根据条件判断是否创建固定定时启动线程池!这个线程池会根据你配置的时间定时启动去执行 flush 方法!这个 flush 方法主要作用定时将缓存的数据触发jdbc写入!

扩展一下:为什么还要判断   executionOptions.getBatchSize() != 1 呢?你想如果最大推送大小只有一条那就是来一条写入jdbc一条那么定时去触发写入就没啥意义了!如果想要实时写入那么也就是来一条我立马写入到jdbc。这个时候在flink内部不会有延迟的!

处理数据:JdbcBatchingOutputFormat#writeRecord

每条数据进来每次都会进入一下逻辑 我们宏观先看一遍  有个印象再来看细节

  • GenericJdbcSinkFunction#invoke

    • SimpleBatchStatementExecutor#addToBatch

    • JdbcBatchingOutputFormat#flush

    • SimpleBatchStatementExecutor#executeBatch

    • JdbcBatchingOutputFormat#writeRecord
       

      设计思路:

      我们想要实现批量写入,那就必须要有批量数据,可是我们数据一条条进来是不是先得有个容器容纳这些批量数据?当容器到达一定的数据量我们就得批量写出去,然后清空容器,再重新装数据。

      flink的实现:

      JdbcBatchingOutputFormat#writeRecord

       @Override
       public final synchronized void writeRecord(In record) throws IOException {
        //写入数据之前发现有之前存在异常 就抛出异常 结束写入!否则则继续写入。
        checkFlushException();
      
        try {
         //将数据添加到容器
         addToBatch(record, jdbcRecordExtractor.apply(record));
         //计数器 统计容器已经有多少数据了
         batchCount++;
         //判断计数器是不是达到 指定容量
         if (executionOptions.getBatchSize() > 0
          && batchCount >= executionOptions.getBatchSize()) {
          flush(); //达到就执行批量输出jdbc
         }
        } catch (Exception e) {
         throw new IOException("Writing records to JDBC failed.", e);
        }
       }
      
      
      
      

      JdbcBatchingOutputFormat#addToBatch 如何批量写入容器

      protected void addToBatch(In original, JdbcIn extracted) throws SQLException {
           //这个jdbcStatementExecutor 前面说了就等于  SimpleBatchStatementExecutor
        jdbcStatementExecutor.addToBatch(extracted);
      }
      
      
      
      

      SimpleBatchStatementExecutor#addToBatch

      class SimpleBatchStatementExecutor<T, V>{
          private final List<V> batch;
          
          SimpleBatchStatementExecutor(...) {
        ...
        this.batch = new ArrayList<>();
       }
          
       @Override
       public void addToBatch(T record) {
        batch.add(valueTransformer.apply(record));
       }
      }
      

      可以看到内部使用一个ArrayList容器容纳待写入的元素, valueTransformer 这里的就是record数据进去record数据出来,没有做什么处理!系统留下的扩展!
       

      系统批量写入jdbc时机?

      有三个他们都调用了 JdbcBatchingOutputFormat#flush

    • JdbcBatchingOutputFormat#writeRecord 写入元素时候判断批量条数达到阈值调用

    • JdbcBatchingOutputFormat#open 上面讲了open里面启动一个定时调度器会调用

    • GenericJdbcSinkFunction#snapshotState  还有一个发生checkPoint时候会调用

JdbcBatchingOutputFormat#flush 方法解析
 @Override
 public synchronized void flush() throws IOException {
  checkFlushException();
  //循环判断是否小于最大重试次数
  for (int i = 1; i <= executionOptions.getMaxRetries(); i++) {
   try {
    //执行jdbc批量写入 然后将统计数据条数值重置为0  跳出循环结束
    attemptFlush();
    batchCount = 0;
    break;
   } catch (SQLException e) {
    LOG.error("JDBC executeBatch error, retry times = {}", i, e);
    if (i >= executionOptions.getMaxRetries()) {
     throw new IOException(e);  //如果循环次数达到最大重试次数 就抛出异常
    }
    try {
     //验证 连接是否有效 无效会重新生成connection和PreparedStatement
     if (!connection.isValid(CONNECTION_CHECK_TIMEOUT_SECONDS)) {
      connection = connectionProvider.reestablishConnection();
      jdbcStatementExecutor.closeStatements();
      jdbcStatementExecutor.prepareStatements(connection);
     }
    } catch (Exception excpetion) {
     LOG.error(
      "JDBC connection is not valid, and reestablish connection failed.",
      excpetion);
     throw new IOException("Reestablish JDBC connection failed", excpetion);
    }
    try {
     Thread.sleep(1000 * i);
    } catch (InterruptedException ex) {
     Thread.currentThread().interrupt();
     throw new IOException(
      "unable to flush; interrupted while doing another attempt",
      e);
    }
   }
  }
 }

 protected void attemptFlush() throws SQLException {
        //这个jdbcStatementExecutor 前面说了就等于  SimpleBatchStatementExecutor
  jdbcStatementExecutor.executeBatch();
 }

SimpleBatchStatementExecutor#executeBatch

 @Override
 public void executeBatch() throws SQLException {
  if (!batch.isEmpty()) {
   for (V r : batch) {
    parameterSetter.accept(st, r);
    st.addBatch();
   }
   st.executeBatch();
   batch.clear();
  }
 }

 这个parameterSetter.accept(st, r) 方法就是调用我例子里面的

st = PreparedStatement r = 每一个元素

这里执行批量写入,然后清空ArrayList容器!

连接器参数Flink19 #

参数是否必填默认值类型描述
connector
必填(none)String指定使用什么类型的连接器,这里应该是'jdbc'
url
必填(none)StringJDBC 数据库 url。
table-name
必填(none)String连接到 JDBC 表的名称。
driver
可选(none)String用于连接到此 URL 的 JDBC 驱动类名,如果不设置,将自动从 URL 中推导。
compatible-mode
可选(none)String数据库的兼容模式。
username
可选(none)StringJDBC 用户名。如果指定了 'username' 和 'password' 中的任一参数,则两者必须都被指定。
password
可选(none)StringJDBC 密码。
connection.max-retry-timeout
可选60sDuration最大重试超时时间,以秒为单位且不应该小于 1 秒。
scan.partition.column
可选(none)String用于将输入进行分区的列名。请参阅下面的分区扫描部分了解更多详情。
scan.partition.num
可选(none)Integer分区数。
scan.partition.lower-bound
可选(none)Integer第一个分区的最小值。
scan.partition.upper-bound
可选(none)Integer最后一个分区的最大值。
scan.fetch-size
可选0Integer每次循环读取时应该从数据库中获取的行数。如果指定的值为 '0',则该配置项会被忽略。
scan.auto-commit
可选trueBoolean在 JDBC 驱动程序上设置 auto-commit 标志, 它决定了每个语句是否在事务中自动提交。有些 JDBC 驱动程序,特别是 Postgres,可能需要将此设置为 false 以便流化结果。
lookup.cache
可选NONE

枚举类型

可选值: NONE, PARTIAL
维表的缓存策略。 目前支持 NONE(不缓存)和 PARTIAL(只在外部数据库中查找数据时缓存)。
lookup.cache.max-rows
可选(none)Integer维表缓存的最大行数,若超过该值,则最老的行记录将会过期。 使用该配置时 "lookup.cache" 必须设置为 "PARTIAL”。请参阅下面的 Lookup Cache 部分了解更多详情。
lookup.partial-cache.expire-after-write
可选(none)Duration在记录写入缓存后该记录的最大保留时间。 使用该配置时 "lookup.cache" 必须设置为 "PARTIAL”。请参阅下面的 Lookup Cache 部分了解更多详情。
lookup.partial-cache.expire-after-access
可选(none)Duration在缓存中的记录被访问后该记录的最大保留时间。 使用该配置时 "lookup.cache" 必须设置为 "PARTIAL”。请参阅下面的 Lookup Cache 部分了解更多详情。
lookup.partial-cache.cache-missing-key
可选trueBoolean是否缓存维表中不存在的键,默认为true。 使用该配置时 "lookup.cache" 必须设置为 "PARTIAL”。
lookup.max-retries
可选3Integer查询数据库失败的最大重试次数。
sink.buffer-flush.max-rows
可选100Integerflush 前缓存记录的最大值,可以设置为 '0' 来禁用它。
sink.buffer-flush.interval
可选1sDurationflush 间隔时间,超过该时间后异步线程将 flush 数据。可以设置为 '0' 来禁用它。注意, 为了完全异步地处理缓存的 flush 事件,可以将 'sink.buffer-flush.max-rows' 设置为 '0' 并配置适当的 flush 时间间隔。
sink.max-retries
可选3Integer写入记录到数据库失败后的最大重试次数。
sink.parallelism
可选(none)Integer用于定义 JDBC sink 算子的并行度。默认情况下,并行度是由框架决定:使用与上游链式算子相同的并行度。

总结

  1. JdbcExecutionOptions 可以配置  批量写入间隔时间  最大写入数量  和 异常容错次数(只支持sql异常)

  2. JdbcConnectionOptions 可以配置数据库的连接参数

  3. 关闭定时写入可以把 BatchIntervalMs设置为0

  4. 实时写入可以把 BatchSize 设置为1

  5. 间隔时间 或者 最大写入数 或者 触发检查点的时候 这三个地方 会触发写入批量写入jdbc

  6. 未开启检查点可能会丢失数据的,开启了检查点需要保证数据库幂等性插入,因为可能会重复插入!

cdc挂掉时间

2024-09-30 07:51

2024-10-02 01:00

2024-10-05 12:50

    中间过节没发现

2024-10-09 17:50

2024-10-11 12:50

2024-10-13 12:50

2024-10-15  7:50

source端的时间  wait_timeout    28800     8H         5.7.32-log

sink                     wait_timeout    86400     24H      8.0.39

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值