Flink必知必会系列之回撤流(RetractStream)源码解读

 全网最全大数据面试提升手册!2023年持续更新!

1. 定义

Flink中,Kafka Source是非回撤流,Group By是回撤流。所谓回撤流,就是可以更新历史数据的流,更新历史数据并不是将发往下游的历史数据进行更改,要知道,已经发往下游的消息是追不回来的。更新历史数据的含义是,在得知某个Key(接在Key BY / Group By后的字段)对应数据已经存在的情况下,如果该Key对应的数据再次到来,会生成一条delete消息和一条新的insert消息发往下游。

2. 示例

public class RetractDemo {
    public static void main(String[] args) throws Exception {
        // set up execution environment
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        // use blink planner in streaming mode
        EnvironmentSettings settings = EnvironmentSettings.newInstance()
                .inStreamingMode()
                .build();
        StreamTableEnvironment tEnv = StreamTableEnvironment.create(env, settings);
        // 用fromElements模拟非回撤消息
        DataStream> dataStream = env.fromElements(new Tuple2<>("hello", 1), new Tuple2<>("hello", 1), new Tuple2<>("hello", 1));
        tEnv.registerDataStream("tmpTable", dataStream, "word, num");
        Table table = tEnv.sqlQuery("select cnt, count(word) as freq from (select word, count(num) as cnt from tmpTable group by word) group by cnt");
        // 启用回撤流机制
        tEnv.toRetractStream(table, TypeInformation.of(new TypeHint>() {
        })).print();
        env.execute();
    }
}

结果:

(true,(1,1))
(false,(1,1))
(true,(2,1))
(false,(2,1))
(true,(3,1))
Flink必知必会系列之回撤流(RetractStream)源码解读_第1张图片
2.1 源码分析
2.1.1 聚合算子回撤

有如下sql: 第一层count,接收kafka source的非回撤流:

SELECT region, count(id) AS order_cnt FROM order_tab GROUP BY region
Flink必知必会系列之回撤流(RetractStream)源码解读_第2张图片

第二层count,接收第一层count的回撤流:

SELECT order_cnt, count(region) as region_cnt FROM order_count_view GROUP BY order_cnt
Flink必知必会系列之回撤流(RetractStream)源码解读_第3张图片

下面来分析一下其源码:

  1. 代码生成

Flink在为SQL语句生成物理执行计划是,会在AggregateUtil.createGroupAggregateFunction方法中生成聚合方法GeneratedAggregations#retract(),并最终利用Janino动态编译框架编译运行。生成GeneratedAggregations

object AggregateUtil {
 private[flink] def createDataStreamGroupAggregateFunction[K](...generateRetraction: Boolean...){
    ...
    // 动态生成聚合方法
    new GroupTableAggProcessFunction[K](
    genAggregations.asInstanceOf[GeneratedTableAggregationsFunction],
    aggregationStateType,
    // 生成聚合函数是否支持retraction,该方法通过判断上游流是否支持回撤来进行返回,true表示支持,false表示不支持
    // 例如kafka source算子不支持回撤流,count聚合算子支持回撤流
    generateRetraction,
    groupings.length,
    queryConfig)
    ...
  }
}
class GroupAggProcessFunction[K](...private val generateRetraction: Boolean...){
  // 代理模式,真正生成的function被GroupTableAggProcessFunction所代理
  private var function: GeneratedTableAggregations = _
  
 override def open(config: Configuration) {
   LOG.debug(s"Compiling TableAggregateHelper: ${genTableAggregations.name} \n\n " +
      s"Code:\n${genTableAggregations.code}")
    
    // 利用janino动态编译框架,动态编译并动态加载聚合函数
    val clazz = compile(
      // 获取类加载器
      getRuntimeContext.getUserCodeClassLoader,
      genAggregations.name,
      genAggregations.code)
    LOG.debug("Instantiating TableAggregateHelper.")
    function = clazz.newInstance()
    function.open(getRuntimeContext)
    ...
  }
}
// janino的使用
...
import org.codehaus.janino.SimpleCompiler
trait Compiler[T] {
  @throws(classOf[CompileException])
  def compile(cl: ClassLoader, name: String, code: String): Class[T] = {
    require(cl != null, "Classloader must not be null.")
    val compiler = new SimpleCompiler()
    compiler.setParentClassLoader(cl)
    try {
      compiler.cook(code)
    } catch {
      case t: Throwable =>
        throw new InvalidProgramException("Table program cannot be compiled. " +
          "This is a bug. Please file an issue.", t)
    }
    compiler.getClassLoader.loadClass(name).asInstanceOf[Class[T]
 }  
}
  1. 回撤

聚合算子中包含两种状态,state 存储中间结果状态(如count(id)值)、cntState存储key对应的消息数量(聚合消息+1,回撤消息-1)。state用于不断更新中间聚合状态,cntState用于判断向下游发送当前新的聚合消息,还是上一次聚合消息对应的回撤消息.

state class GroupAggProcessFunction[K](...)
    extends ProcessFunctionWithCleanupState[K, CRow, CRow](queryConfig){
      // 每一条消息都会经过该方法处理
      override def processElement(
      // 消息的格式是CRow,包含一个change字段,标识是否是聚合消息;true标识聚合消息,false标识回撤消息
      inputC: CRow,
      ctx: KeyedProcessFunction[K, CRow, CRow]#Context,
      out: Collector[CRow]): Unit = {
      ...
        
          // 中间状态,即上一次的聚合结果
       var accumulators = state.value()
          // key对应的消息数量,对于不支持回撤流的消息,取值可以>1;对于支持回撤流的消息,取值为0或1
       var inputCnt = cntState.value()
          // 如果是聚合消息
          if (inputC.change) {
         inputCnt += 1
         // accumulate()是聚合方法,例如count()
         function.accumulate(accumulators, input)
         function.setAggregationResults(accumulators, newRow.row)
       } else {
            // 对于支持回撤流的消息,inputCnt取值为0或1,因为某条消息如果要发生变更,只有先撤回,再新增新消息,如图片中的(+ SH 1),(- SH 1),(+ SH 2)
         inputCnt -= 1
            // function对象的retract()方法,只有在判断上游算子支持回撤流时才会生成;在当前场景下为与count()相反的逻辑,即对input中key的count聚合进行减一操作
         function.retract(accumulators, input)
         function.setAggregationResults(accumulators, newRow.row)
       }
         ...
        
          // 如果该key对应的中间状态还存在,即如果该key还存在,发送最新的聚合消息
          if (inputCnt != 0) {
            // update the state
         state.update(accumulators)
         cntState.update(inputCnt)
            ...
            // newRow/prevRow分别代表新消息和对上一次消息的回撤消息,具体请参考源码
           out.collect(newRow)
          }else{
            // 如果该key的聚合消息和回撤消息数量相等,则认为该key即将消失,发送回撤消息
            out.collect(prevRow)
         // 清除状态
         state.clear()
         cntState.clear()
          }
     ...
      }
}
2.1.2 Sink算子回撤

上一小节是对于聚合算子回撤流的分析,这一节讲讲sink算子的回撤。官方对于sink的插入模式有以下三种描述:

Append 模式 - 该模式用户在定义Sink的DDL时候不定义PK,在Apache Flink内部生成的所有只有INSERT语句;

Upsert 模式 - 该模式用户在定义Sink的DDL时候可以定义PK,在Apache Flink内部会根据事件打标(retract机制)生成INSERT/UPDATE和DELETE 语句,其中如果定义了PK, UPDATE语句按PK进行更新,如果没有定义PK UPDATE会按整行更新;

Retract 模式 - 该模式下会产生INSERT和DELETE两种信息,Sink Connector 根据这两种信息构造对应的数据操作指令;

但其实sink算子是否支持回撤流,要根据sink数据源的特性而定。例如kafka sink只支持append模式,jdbc sink在Flink1.11中只支持upsert(不配置primary key会报错)。这都跟sink数据源的特性密切相关.

  1. kafka sink

如kafka是利用log中顺序追加消息的方式存储消息,因此只支持append模式,网上有修改kafka sink connector以支持upsert的方法:将聚合算子中的回撤消息(false)过滤掉,只留下聚合消息(true),并写入kafka,带来的现象就是一个聚合结果会多次出现在kafka中,算是一种阉割版的upsert模式,代码如下:

public abstract class KafkaTableSinkBase implements AppendStreamTableSink {
  // 将 kafka 改成 upsert
 @Override
 public DataStreamSink consumeDataStream(DataStream> dataStream) {
    final SinkFunction kafkaProducer = createKafkaProducer(
            topic,
            properties,
            serializationSchema,
            partitioner);
    // 过滤掉回撤消息
    return dataStream.filter(t -> t.f0).map(t -> t.f1)
            .addSink(kafkaProducer)
            .setParallelism(dataStream.getParallelism())
            .name(TableConnectorUtils.generateRuntimeName(this.getClass(), getFieldNames()));
 }
}
  1. JDBC Sink

jdbc sink我们以mysql为例,理论上mysql支持append、upsert、retract中的所有模式,但是Flink1.11中只提供了upsert模式。这很好理解,CDC作为Flink1.11的重大特性,append和retract对于CDC都没有意义,只有upsert符合CDC的初衷。

注意:Flink 1.11中ddl中不配置primary key会报错,尽管Flink并不会校验primary key的正确性,而只是upsert模式的一种表征.

其原理是利用了mysql的upsert原子语句(必须有主键)来实现,如下:

INSERT INTO `TABLE_SINK_SYNC`(`eno`, `ename`, `esex`, `ebirthday`, `eteam`, `eincome`)
VALUES (1, 2, 3, 4, 5, 6)
ON DUPLICATE KEY UPDATE `eno`=VALUES(`eno`),
                        `ename`=VALUES(`ename`),
                        `esex`=VALUES(`esex`),
                        `ebirthday`=VALUES(`ebirthday`),
                        `eteam`=VALUES(`eteam`),
                        `eincome`=VALUES(`eincome`)

如果upsert操作不是原子的,很可能会发生并发问题,带来预料不到的后果。

2.1.3 聚合算子回撤 VS Sink算子回撤
  1. 原理不同

聚合算子通过在state和cntState这两个状态分别维护中间聚合状态和某key所对应的消息数量(聚合消息+1,回撤消息-1),如果cntState.value() = 1,则下发最新的聚合消息;如果cntState.value() = 0,则将state中维护的上一次的聚合状态作为回撤消息进行下发。

Sink算子则是解析canal或debezium中的op字段,如果op=create,则取after字段中的有效数据进行下发;如果op=update,则分别取before和after中的有效数据进行下发;如果op=delete,则取before中的有效数据进行下发。

public final class DebeziumJsonDeserializationSchema implements DeserializationSchema {
 @Override
 public void deserialize(byte[] message, Collector out) throws IOException {
     GenericRowData row = (GenericRowData) jsonDeserializer.deserialize(message);
   GenericRowData payload;
   if (schemaInclude) {
    payload = (GenericRowData) row.getField(0);
   } else {
    payload = row;
   }

   GenericRowData before = (GenericRowData) payload.getField(0);
   GenericRowData after = (GenericRowData) payload.getField(1);
   String op = payload.getField(2).toString();
      // 关键逻辑
   if (OP_CREATE.equals(op) || OP_READ.equals(op)) {
    after.setRowKind(RowKind.INSERT);
    out.collect(after);
   } else if (OP_UPDATE.equals(op)) {
    before.setRowKind(RowKind.UPDATE_BEFORE);
    after.setRowKind(RowKind.UPDATE_AFTER);
    out.collect(before);
    out.collect(after);
   } else if (OP_DELETE.equals(op)) {
    before.setRowKind(RowKind.DELETE);
    out.collect(before);
   } else {
    if (!ignoreParseErrors) {
     throw new IOException(format(
      "Unknown \"op\" value \"%s\". The Debezium JSON message is '%s'", op, new String(message)));
    }
   }
  }
}

最后,利用JDBC原子语句ON DUPLICATE KEY UPDATE实现upsert语义。

  1. 数据结构不同

class CRow(var row: Row, var change: Boolean) {}

聚合算子中的数据格式为CRow.

public final class GenericRowData implements RowData {
 private final Object[] fields;
  // Flink1.11新增的属性
 private RowKind kind;
}
public enum RowKind {
 INSERT("+I", (byte) 0),
 UPDATE_BEFORE("-U", (byte) 1),
 UPDATE_AFTER("+U", (byte) 2),
 DELETE("-D", (byte) 3);
}
结论:
  • 聚合算子和Sink算子关于回撤的概念相似,但原理不同且使用场景也不同,聚合算子的回撤用于聚合状态的更新,Sink算子的回撤则更多的是应用于CDC场景。

  • 聚合算子的撤回机制,保证了FlinkSQL持续查询/增量查询的正确语义;而Sink算子的回撤机制,保证了CDC场景下的正确语义。

如果这个文章对你有帮助,不要忘记 「在看」 「点赞」 「收藏」 三连啊喂!

Flink必知必会系列之回撤流(RetractStream)源码解读_第4张图片

e93aa68e1fb91c6a8ad37e6b1ccc4b26.jpeg

2022年全网首发|大数据专家级技能模型与学习指南(胜天半子篇)

互联网最坏的时代可能真的来了

我在B站读大学,大数据专业

我们在学习Flink的时候,到底在学习什么?

193篇文章暴揍Flink,这个合集你需要关注一下

Flink生产环境TOP难题与优化,阿里巴巴藏经阁YYDS

Flink CDC我吃定了耶稣也留不住他!| Flink CDC线上问题小盘点

我们在学习Spark的时候,到底在学习什么?

在所有Spark模块中,我愿称SparkSQL为最强!

硬刚Hive | 4万字基础调优面试小总结

数据治理方法论和实践小百科全书

标签体系下的用户画像建设小指南

4万字长文 | ClickHouse基础&实践&调优全视角解析

【面试&个人成长】2021年过半,社招和校招的经验之谈

大数据方向另一个十年开启 |《硬刚系列》第一版完结

我写过的关于成长/面试/职场进阶的文章

当我们在学习Hive的时候在学习什么?「硬刚Hive续集」

你可能感兴趣的:(flink,java,数据库,大数据,开发语言)