一、状态
在流计算场景中,数据没有边界源源不断的流入的,每条数据流入都可能会触发计算,比如在进行count或sum这些操作,是选择每次触发计算将所有流入的历史数据重新计算一边还是每次计算都基于上次计算结果进行增量计算呢? 从综合考虑角度,很多人都会 选择增量计算,那么问题就产生了:上一次的中间计算结果保存在哪里?内存?这其中会由于本身的网络,硬件或软件等问题造成某个计算节点失败,对应的上次计算结果就会丢失,在节点恢复时,是需要将所有历史数据重新计算一遍的,对于这样的结果大家是很难接受的。
而在flink中提出了state用来存放计算过程的节点中间结果或元数据等,并提供Exactly-Once语义,例如:执行aggregation时在state中记录中间聚合结果,再如从kafka中摄取记录时,是需要记录对应的partition的offset,而这些state数据在计算过程中会进行持久化的。state就变成了与时间相关的是对flink任务内部数据的快照。
由于流计算大多数场景下都是增量计算的,数据逐条被处理,每次当前结果都是基于上一次计算结果之上进行处理的,这也势必要将上一次的计算结果进行存储持久化,无论是机器,网络,脏数据等原因导致的程序错误,都能在job进行任务恢复时提供支持。基于这些已被持久化的state,而非将历史的数据重新计算一遍。
以下是一些有状态的计算的例子。
1 所有类型的窗口。例如,计算过去一小时的平均温度,就是有状态的计算;
2 所有用于复杂事件处理的状态机。例如,若在一分钟内收到两个相差20度以上的温度读数,则发出警告,这是有状态的计算;
3 流与流之间的所有关联操作,以及流与静态表或动态表之间的关联操作。
流式计算分为无状态和有状态两种情况。无状态的计算观察每个独立事件,并根据最后一个事件输出结果。例如,流处理应用程序从传感器接收温度读数,并在温度超过90度时发出警告。有状态的计算则会基于多个事件输出结果。
下图展示了无状态流处理和有状态流处理的主要区别。无状态流处理分别接收每条数据记录(图中的黑条),然后根据最新输入的数据生成输出数据(白条)。有状态流处理会维护状态(根据每条输入记录进行更新),并基于最新输入的记录和当前的状态值生成输出记录(灰条)。
状态的特点:
(1)由一个任务维护,并且用来计算某个结果的所有数据,都属于这个任务的状态
(2)可以认为状态就是一个本地变量,可以被任务的业务逻辑访问
(3)Flink 会进行状态管理,包括状态一致性、故障处理以及高效存储和访问,以便开发人员可以专注于应用程序的逻辑
在Flink中,状态始终与特定算子相关联。总的来说,有两种类型的状态:
(1) Keyed State
(2) Operator State
Keyed State和Operator State,可以以两种形式存在:
(1) 原始状态(raw state)
(2) 托管状态(managed state)
托管状态是由Flink框架管理的状态,而原始状态,由用户自行管理状态具体的数据结构,框架在做checkpoint的时候,使用byte[]来读写状态内容,对其内部数据结构一无所知。通常在DataStream上的状态推荐使用托管的状态,当实现一个用户自定义的operator时,会使用到原始状态。
(1)map/filter/flatmap本来是无状态的,但是可以通过实现RichFunction在其自定义状态中操作;
(2)Reduce/Aggregate/Window本来就有状态,底层由Filnk管理,也可以通过实现RichFuction自定义状态;
(3)ProcessFuction是一种特殊的函数类,是.process()方法的参数,它也实现了RichFunction接口,是一个特殊的富函数,DateStream/KeyedStream/ConnectedStream/WindowedStream等等都可以调用.process()方法,传入的是不同的ProcessFunction;
1 算子状态(operator state)
算子状态的作用范围限定为算子任务。这意味着由同一并行任务所处理的所有数据都可以访问到相同的状态,状态对于同一任务而言是共享的。算子状态不能由相同或不同算子的另一个任务访问
。
Flink为算子状态提供三种基本数据结构:
列表状态(List state)
将状态表示为一组数据的列表。
举例来说,Flink中的Kafka Connector,就使用了operator state。它会在每个connector实例中,保存该实例中消费topic的所有(partition, offset)映射
联合列表状态(Union list state)
也将状态表示为数据的列表。它与常规列表状态的区别在于,在发生故障时,或者从保存点(savepoint)启动应用程序时如何恢复。
广播状态(Broadcast state)
如果一个算子有多项任务,而它的每项任务状态又都相同,那么这种特殊情况最适合应用广播状态。
2 键控状态(keyed state)
键控状态是根据输入数据流中定义的键(key)来维护和访问的。Flink为每个键值维护一个状态实例,并将具有相同键的所有数据,都分区到同一个算子任务中,这个任务会维护和处理这个key对应的状态。当任务处理一条数据时,它会自动将状态的访问范围限定为当前数据的key。因此,具有相同key的所有数据都会访问相同的状态。Keyed State很类似于一个分布式的key-value map数据结构,只能用于KeyedStream(keyBy算子处理之后)。
Flink的Keyed State支持以下数据类型:
(1) ValueState<T>:即类型为T的单值状态。这个状态与对应的key绑定,是最简单的状态了。它可以通过update方法更新状态值,通过value()方法获取状态值。
get操作: ValueState.value()
set操作: ValueState.update(value: T)
(2) ListState<T>:即key上的状态值为一个列表。可以通过add方法往列表中附加值;也可以通过get()方法返回一个Iterable<T>来遍历状态值。
ListState.add(value: T)
ListState.addAll(values: java.util.List[T])
ListState.get()返回Iterable[T]
ListState.update(values: java.util.List[T])
(3) ReducingState<T>:这种状态通过用户传入的reduceFunction,每次调用add方法添加值的时候,会调用reduceFunction,最后合并到一个单一的状态值。
(4) MapState<UK, UV>:即状态值为一个map。用户通过put或putAll方法添加元素。
MapState.get(key: K)
MapState.put(key: K, value: V)
MapState.contains(key: K)
MapState.remove(key: K)
(5) AggregatingState[I, O]
以下是代码示例:
class Myprocessor extends KeyedProcessFunction[String,SensorReading,Int]{
// lazy val mystate:ValueState[Int] = getRuntimeContext.getState(new ValueStateDescriptor[Int]("my-state",classOf[Int]))
//通过RuntimeContext注册StateDescriptor。StateDescriptor以状态state的名字和存储的数据类型为参数。
lazy val myListState: ListState[String] = getRuntimeContext.getListState(new ListStateDescriptor[String]("myliststate", classOf[String]))
lazy val mapState: MapState[String, Double] = getRuntimeContext.getMapState(new MapStateDescriptor[String, Double]("MapState", classOf[String], classOf[Double]))
lazy val ruduceState: ReducingState[SensorReading] = getRuntimeContext.getReducingState(new ReducingStateDescriptor[SensorReading]("reduceState",
new ReduceFunction[SensorReading] {
override def reduce(t: SensorReading, t1: SensorReading): SensorReading = SensorReading(t.id,t.timestamp.max(t1.timestamp),t.temperature.min(t1.temperature))
},
classOf[SensorReading]))
var mystate: ValueState[Int]
//在open()方法中创建state变量
override def open(parameters: Configuration): Unit = {
mystate = getRuntimeContext.getState(new ValueStateDescriptor[Int]("my-state",classOf[Int]))
}
override def processElement(i: SensorReading, context: KeyedProcessFunction[String, SensorReading, Int]#Context, collector: Collector[Int]): Unit = {
mystate.value()
mystate.update(1)
myListState.add("hello")
mapState.put("key1",23)
mapState.get("key1")
ruduceState.add(SensorReading("2",22131233232L,34))
}
需要注意的是,以上所述的State对象,仅仅用于与状态进行交互(更新、删除、清空等),而真正的状态值,有可能是存在内存、磁盘、或者其他分布式存储系统中。相当于我们只是持有了这个状态的句柄。
3 状态后端
每传入一条数据,有状态的算子任务都会读取和更新状态;
由于有效的状态访问对于处理数据的低延迟至关重要,因此每个并行任务都会在本地内存维护其状态,以确保快速的状态访问;
状态的存储、访问以及维护,由一个可插入的组件决定,这个组件就叫做状态后端(state backend)
状态后端主要负责两件事:本地的状态管理,以及将检查点(checkpoint)状态写入远程存储
MemoryStateBackend
内存级的状态后端,state数据保存在java堆内存中,执行checkpoint的时候,会把state的快照数据保存到jobmanager的内存中,基于内存的state backend在生产环境下不建议使用。
特点:快速、低延迟,但不稳定
FsStateBackend
执行checkpoint的时候,会把state的快照数据保存到配置的文件系统中;而对于本地状态,跟 MemoryStateBackend 一样,也会存在 TaskManager 的 JVM 堆上
特点:同时拥有内存级的本地访问速度,和更好的容错保证
RocksDBStateBackend
RocksDB跟上面的都略有不同,它会在本地文件系统中维护状态,state会直接写入本地rocksdb中。同时它需要配置一个远端的filesystem uri(一般是HDFS),在做checkpoint的时候,会把本地的数据直接复制到filesystem中。fail over的时候从filesystem中恢复到本地,RocksDB克服了state受内存限制的缺点,同时又能够持久化到远端文件系统中,比较适合在生产中使用。
修改State Backend的两种方式
第一种:单任务调整
修改当前任务代码
env.setStateBackend(new FsStateBackend("hdfs://namenode:9000/flink/checkpoints"));
或者new MemoryStateBackend()
或者new RocksDBStateBackend(filebackend, true);【需要添加第三方依赖】
第二种:全局调整
修改flink-conf.yaml
state.backend: filesystem
state.checkpoints.dir: hdfs://namenode:9000/flink/checkpoints
注意:state.backend的值可以是下面几种:
jobmanager(MemoryStateBackend)
filesystem(FsStateBackend)
rocksdb(RocksDBStateBackend)
二、Checkpoint
Checkpoint是Flink实现容错机制最核心的功能,它能够根据配置周期性地基于Stream中各个Operator/task的状态来生成快照,从而将这些状态数据定期持久化存储下来,当Flink程序一旦意外崩溃时,重新运行程序时可以有选择地从这些快照进行恢复,从而修正因为故障带来的程序数据异常。
Flink的checkpoint机制是state的持久化存储的前提:持久化state的存储系统需要支持在一定时间内重放事件。典型例子是持久化的消息队列(比如Apache Kafka,RabbitMQ等)或文件系统(比如HDFS,S3,GFS等)。
Flink 故障恢复机制的核心,就是应用状态的一致性检查点(Checkpoint);
有状态流应用的一致检查点,其实就是所有任务的状态,在某个时间点的一份拷贝(一份快照);这个时间点,应该是所有任务都恰好处理完一个相同的输入数据的时候。
在执行流应用程序期间,Flink 会定期保存状态的一致检查点,如果发生故障, Flink 将会使用最近的检查点来一致恢复应用程序的状态,并重新启动处理流程;
1 CheckPoint任务重启流程
遇到故障之后,第一步就是重启应用
第二步是从 checkpoint 中读取状态,将状态重置
从检查点重新启动应用程序后,其内部状态与检查点完成时的状态完全相同
第三步:开始消费并处理检查点到发生故障之间的所有数据
这种检查点的保存和恢复机制可以为应用程序状态提供“精确一次”(exactly-once)的一致性,因为所有算子都会保存检查点并恢复其所有状态,这样一来所有的输入流就都会被重置到检查点完成时的位置
2 CheckPoint算法解析
2.1 检查点的实现算法
一种简单的想法
—— 暂停应用,保存状态到检查点,再重新恢复应用
Flink 的改进实现
—— 基于 Chandy-Lamport 算法的分布式快照
—— 将检查点的保存和数据处理分离开,不暂停整个应用
CheckPoint存储位置由StateBackend决定,一般放在持久化存储空间(FS或者RockDB),JobManager触发一个CheckPoint操作,会把CheckPoint中所有任务状态的拓扑结构保存下来。
Barrier和Watermark类似可以看作插入数据流的特殊数据。
2.2 Flink 检查点算法
(1)检查点分界线(Checkpoint Barrier)
Flink 的检查点算法用到了一种称为分界线(barrier)的特殊数据形式,用来把一条流上数据按照不同的检查点分开;
分界线之前到来的数据导致的状态更改,都会被包含在当前分界线所属的检查点中;而基于分界线之后的数据导致的所有更改,就会被包含在之后的检查点中。
(2)检查点算法过程解析
现在是一个有两个输入流的应用程序,用并行的两个 Source 任务来读取
JobManager 会向每个 source 任务发送一条带有新检查点 ID 的消息,通过这种方式来启动检查点
数据源将它们的状态写入检查点,并分别发出一个检查点 barrier
状态后端在状态存入检查点之后,会返回通知给 source 任务,source 任务就会向 JobManager 确认检查点完成
分界线对齐:barrier 向下游传递,sum 任务会等待所有输入分区的 barrier 到达,对于barrier已经到达的分区,继续到达的数据会被缓存,而barrier尚未到达的分区,数据会被正常处理
当收到所有输入分区的 barrier 时,任务就将其状态保存到状态后端的检查点中,然后将 barrier 继续向下游转发
向下游转发检查点 barrier 后,任务继续正常的数据处理
Sink 任务向 JobManager 确认状态保存到 checkpoint 完毕
当所有任务都确认已成功将状态保存到检查点时,检查点就真正完成了
三、SavePoint
Flink 还提供了可以自定义的镜像保存功能,就是保存点(savepoints)
原则上,创建保存点使用的算法与检查点完全相同,因此保存点可以认为就是具有一些额外元数据的检查点
Flink不会自动创建保存点,因此用户(或者外部调度程序)必须明确地触发创建操作
保存点是一个强大的功能。除了故障恢复外,保存点可以用于:
(1)有计划的手动备份
(2)更新应用程序
(3)版本迁移,暂停和重启应用等等
1 CheckPoint VS SavePoint
CheckPoint:
应用定时触发,用于保存状态,会过期
内部应用失败重启的时候使用
SavePoint:
用户手动执行,是指向Checkpoint的指针,不会过期
在升级的情况下使用
注意:为了能够在作业的不同版本之间以及 Flink 的不同版本之间顺利升级,强烈推荐程序员通过 uid(String) 方法手动的给算子赋予 ID,这些 ID 将用于确定每一个算子的状态范围。如果不手动给各算子指定 ID,则会由 Flink 自动给每个算子生成一个 ID。只要这些 ID 没有改变就能从保存点(savepoint)将程序恢复回来。而这些自动生成的 ID 依赖于程序的结构,并且对代码的更改是很敏感的。因此,强烈建议用户手动的设置 ID。
2 Savepoint的使用
1:在flink-conf.yaml中配置Savepoint存储位置
不是必须设置,但是设置后,后面创建指定Job的Savepoint时,可以不用在手动执行命令时指定Savepoint的位置。
state.savepoints.dir: hdfs://namenode:9000/flink/savepoints
2:触发一个savepoint【直接触发或者在cancel的时候触发】
bin/flink savepoint jobId [targetDirectory] [-yid yarnAppId]【针对on yarn模式需要指定-yid参数】
bin/flink cancel -s [targetDirectory] jobId [-yid yarnAppId]【针对on yarn模式需要指定-yid参数】
3:从指定的savepoint启动job
bin/flink run -s savepointPath [runArgs]
四、CheckPoint的配置
1 CheckPoint的使用
//默认checkpoint功能是disabled的,想要使用的时候需要先启用
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 每隔1000 ms进行启动一个检查点【设置checkpoint的周期】
env.enableCheckpointing(1000);
// 高级选项:
// 设置模式为exactly-once (这是默认值)
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 确保检查点之间有至少500 ms的间隔【checkpoint最小间隔】,留给任务执行时间
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
// 检查点必须在一分钟内完成,或者被丢弃【checkpoint的超时时间】
env.getCheckpointConfig().setCheckpointTimeout(60000);
// 同一时间只允许进行一个检查点
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
// 表示一旦Flink处理程序被cancel后,会保留Checkpoint数据,以便根据实际需要恢复到指定的Checkpoint
env.getCheckpointConfig().enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
2 保存多个CheckPoint
默认情况下,如果设置了Checkpoint选项,则Flink只保留最近成功生成的1个Checkpoint,而当Flink程序失败时,可以从最近的这个Checkpoint来进行恢复。但是,如果我们希望保留多个Checkpoint,并能够根据实际需要选择其中一个进行恢复,这样会更加灵活,比如,我们发现最近4个小时数据记录处理有问题,希望将整个状态还原到4小时之前。
Flink可以支持保留多个Checkpoint,需要在Flink的配置文件conf/flink-conf.yaml中,添加如下配置,指定最多需要保存Checkpoint的个数;
state.checkpoints.num-retained: 20
这样设置以后就查看对应的Checkpoint在HDFS上存储的文件目录;
hdfs dfs -ls hdfs://namenode:9000/flink/checkpoints
如果希望回退到某个Checkpoint点,只需要指定对应的某个Checkpoint路径即可实现。
3 CheckPoint的恢复
五、Restart Strategies(重启策略)
Flink支持不同的重启策略,以在故障发生时控制作业如何重启。
集群在启动时会伴随一个默认的重启策略,在没有定义具体重启策略时会使用该默认策略。 如果在工作提交时指定了一个重启策略,该策略会覆盖集群的默认策略。默认的重启策略可以通过 Flink 的配置文件 flink-conf.yaml 指定。配置参数 restart-strategy 定义了哪个策略被使用。
常用的重启策略:
(1)固定间隔 (Fixed delay)
(2)失败率 (Failure rate)
(3)无重启 (No restart)
如果没有启用 checkpoint,则使用无重启 (no restart) 策略。
如果启用了 checkpoint,但没有配置重启策略,则使用固定间隔 (fixed-delay) 策略,其中 Integer.MAX_VALUE 参数是尝试重启次数。
重启策略可以在flink-conf.yaml中配置,表示全局的配置。也可以在应用代码中动态指定,会覆盖全局配置。
1 重启策略之固定间隔 (Fixed delay)
2 重启策略之失败率 (Failure rate)
3 重启策略之无重启 (No restart)
六、状态一致性
1 什么是状态一致性?
(1)有状态的流处理,内部每个算子任务都可以有自己的状态
(2)对于流处理器内部来说,所谓的状态一致性,其实就是我们所说的计算结果要保证准确
(3)一条数据不应该丢失,也不应该重复计算
(4)在遇到故障时可以恢复状态,恢复以后的重新计算,结果应该也是完全正确的。
2 状态一致性分类
AT-MOST-ONCE(最多一次)
当任务故障时,最简单的做法是什么都不干,既不恢复丢失的状态,也不重播丢失的数据。At-most-once 语义的含义是最多处理一次事件。
AT-LEAST-ONCE(至少一次)
在大多数的真实应用场景,我们希望不丢失事件。这种类型的保障称为 at-least-once,意思是所有的事件都得到了处理,而一些事件还可能被处理多次。
EXACTLY-ONCE(精确一次)
恰好处理一次是最严格的保证,也是最难实现的。恰好处理一次语义不仅仅意味着没有事件丢失,还意味着针对每一个数据,内部状态仅仅更新一次。
3 一致性检查点(checkpoint)
Flink 使用了一种轻量级快照机制 —— 检查点(checkpoint)来保证 exactly-once 语义
有状态流应用的一致检查点,其实就是:所有任务的状态,在某个时间点的一份拷贝(一份快照)。而这个时间点,应该是所有任务都恰好处理完一个相同的输入数据的时候。
应用状态的一致检查点,是 Flink 故障恢复机制的核心
4 端到端(End To End)状态一致性
目前我们看到的一致性保证都是由流处理器实现的,也就是说都是在 Flink 流处理器内部保证的;而在真实应用中,流处理应用除了流处理器以外还包含了数据源(例如 Kafka)和输出到持久化系统
端到端的一致性保证,意味着结果的正确性贯穿了整个流处理应用的始终;每一个组件都保证了它自己的一致性
整个端到端的一致性级别取决于所有组件中一致性最弱的组件
5 端到端 Exactly-Once
内部保证 —— checkpoint
source 端 —— 可重设数据的读取位置
sink 端 —— 从故障恢复时,数据不会重复写入外部系统
幂等写入
事务写入
5.1 幂等写入(Idempotent Writes)
所谓幂等操作,是说一个操作,可以重复执行很多次,但只导致一次结果更改,也就是说,后面再重复执行就不起作用了
5.2 事务写入(Transactional Writes)
事务(Transaction)
应用程序中一系列严密的操作,所有操作必须成功完成,否则在每个操作中所作的所有更改都会被撤消
具有原子性:一个事务中的一系列的操作要么全部成功,要么一个都不做
实现思想
构建的事务对应着 checkpoint,等到 checkpoint 真正完成的时候,才把所有对应的结果写入 sink 系统中
实现方式
(1)预写日志
(2) 两阶段提交
5.2.1 预写日志(Write-Ahead-Log,WAL)
把结果数据先当成状态保存,然后在收到 checkpoint 完成的通知时,一次性写入 sink 系统
简单易于实现,由于数据提前在状态后端中做了缓存,所以无论什么 sink 系统,都能用这种方式一批搞定
DataStream API 提供了一个模板类:GenericWriteAheadSink,来实现这种事务性 sink
缺点在于如果Sink时发生故障,数据有可能会丢失
5.2.2 两阶段提交(Two-Phase-Commit,2PC)
对于每个 checkpoint,sink 任务会启动一个事务,并将接下来所有接收的数据添加到事务里
然后将这些数据写入外部 sink 系统,但不提交它们 —— 这时只是“预提交”
当它收到 checkpoint 完成的通知时,它才正式提交事务,实现结果的真正写入
这种方式真正实现了 exactly-once,它需要一个提供事务支持的外部 sink 系统。Flink 提供了 TwoPhaseCommitSinkFunction 接口。
5.2.2.1 2PC 对外部 sink 系统的要求
(1)外部 sink 系统必须提供事务支持,或者 sink 任务必须能够模拟外部系统上的事务
(2)在 checkpoint 的间隔期间里,必须能够开启一个事务并接受数据写入
(3)在收到 checkpoint 完成的通知之前,事务必须是“等待提交”的状态。在故障恢复的情况下,这可能需要一些时间。如果这个时候sink系统关闭事务(例如超时了),那么未提交的数据就会丢失
(4)sink 任务必须能够在进程失败后恢复事务
(5)提交事务必须是幂等操作
5.2.2.2 Exactly-once 两阶段提交流程解析
JobManager 协调各个 TaskManager 进行 checkpoint 存储 checkpoint保存在 StateBackend中,默认StateBackend是内存级的,也可以改为文件级的进行持久化保存
当 checkpoint 启动时,JobManager 会将检查点分界线(barrier)注入数据流
barrier会在算子间传递下去
每个算子会对当前的状态做个快照,保存到状态后端
checkpoint 机制可以保证内部的状态一致性
每个内部的 transform 任务遇到 barrier 时,都会把状态存到 checkpoint 里 sink 任务首先把数据写入外部 kafka,这些数据都属于预提交的事务;遇到 barrier 时,把状态保存到状态后端,并开启新的预提交事务
当所有算子任务的快照完成,也就是这次的 checkpoint 完成时,JobManager 会向所有任务发通知,确认这次 checkpoint 完成
sink 任务收到确认通知,正式提交之前的事务,kafka 中未确认数据改为“已确认”
5.2.2.2 Exactly-once 两阶段提交步骤
第一条数据来了之后,开启一个 kafka 的事务(transaction),正常写入 kafka 分区日志但标记为未提交,这就是“预提交”
jobmanager 触发 checkpoint 操作,barrier 从 source 开始向下传递,遇到 barrier 的算子将状态存入状态后端,并通知 jobmanager
sink 连接器收到 barrier,保存当前状态,存入 checkpoint,通知 jobmanager,并开启下一阶段的事务,用于提交下个检查点的数据
jobmanager 收到所有任务的通知,发出确认信息,表示 checkpoint 完成
sink 任务收到 jobmanager 的确认信息,正式提交这段时间的数据
外部kafka关闭事务,提交的数据可以正常消费了。
6 不同 Source 和 Sink 的一致性保证
7 Flink+Kafka 端到端状态一致性的保证
内部 —— 利用 checkpoint 机制,把状态存盘,发生故障的时候可以恢复,保证内部的状态一致性
source —— kafka consumer 作为 source,可以将偏移量保存下来,如果后续任务出现了故障,恢复的时候可以由连接器重置偏移量,重新消费数据,保证一致性
sink —— kafka producer 作为sink,采用两阶段提交 sink,需要实现一个 TwoPhaseCommitSinkFunction