Exactly Once语义在Flink中的实现

175 阅读12分钟

这是我参与「第四届青训营 」笔记创作活动的的第4天

Flink案例很敷衍,有时间花时间看看

数据流和动态表

如何在数据流上执行SQL语句,说明流式处理中状态的概念。

传统的SQL首先处理的表是有界的,这也决定了查询时可以访问到完整的数据,每次查询都是批处理产生固定大小结果后终止;而流可以是一个无限元组序列,查询无法访问到所有的数据,所以查询会不断更新,永不终止。

数据流和动态表的转换 image.png

在流上定义表

image.png

动态表与表示批处理数据的静态表不同,是随时间变化的,不过查询方式和静态表是一样的。

连续查询

不过查出来的东西肯定不一样,如下图 image.png 比如,查完marry之后不会终止这个查询,会等待其他人的访问,等待下一次Mary的访问,查询结果会不断更新,累计对该网站的点击量,产生新的一张动态表。

在任何时候,连续查询的结果在语义上与批处理模式在输入表快照上执行的相同查询的结果相同。

image.png

上图左边每个大括号表示一个窗口,右边每个大括号代表一个时间表,TUMBLE_END 代表窗口结束时间,上述SQL指定了窗口结束时间,时间间隔为1小时。比如,从12点到1点Bob点击了1次,之后1点01分又访问一次,由于结束时间已经不一样,所以不会更新之前的记录,只会新插入一条记录。

对比

虽然上述两个查询示例看起来相似,都计算分组计数聚合,但存在一个重要的不同

  • 第一个查询更新先前输出的结果,即定义结果表的changelog流包含INSERT和UPDATE操作
  • 第二个查询只会附加到结果表,即结果表的changelog流只包含INSERT操作

Retract消息的产生

image.png

插入、更新、删除都支持的动态表会转换为撤回流。

撤回流包含两类消息:添加(Add)消息撤回(Retract)消息

动态表通过将 INSERT 编码为 add 消息、DELETE 编码为 retract 消息、UPDATE 编码为被更改行(更改前)的retract 消息和更新后行(新行)的 add 消息,转换为 retract 流。

当Mary第二次点击时,先回撤第一条记录,再插入更新后的记录。

状态

从刚才的例子,我们能看出动态表的更新依赖于之前计数记录,所以说需要存储每位用户的URL计数,以便能够增加该计数并在输入表接收新行时发送新结果,所以说这种查询需要维护更多的状态(state)数据

  • 数据流和动态表之间的转换
  • 在数据流的查询不会终止
  • 查询可能会有的状态,用来不断更新查询的结果

查询中出现故障怎么办?

故障发生时,Flink自身如何从故障中恢复

  • at-most-once:出现故障的时候,啥也不做,能保证所有时间处理数据流,不会有其他开销,处理时延低
  • at-least-once:保证每条数据至少被处理一次,但是对同一条数据可能存在重复消费
  • exactly-once:最严格的语义,从输出结果来看,每条数据均被消费且仅消费一次,仿佛故障从未发生

如何保证对数据消费的不重不漏

Exactly-Once和Checkpoint

状态快照与恢复

下图,是一个持续的多少以内的奇偶数分别求和的快照建立与错误恢复的例子 image.png

上图,source偏移量到5的时候,对几个算子建立快照source(5),sum_even(2+4),sum_odd(1+3+5),这样就做了一个checkpoint,并将状态保存至状态后端了

image.png

上图,source到7时,sum_odd出错

  • recovery1:立即重启应用,source,sum_even,sum_odd状态皆被清空;
  • recovery2:再从checkpoint读取状态,恢复状态,这时其内部状态与检查点完成时的状态完全相同。
  • recovery3:继续处理后续计算source(7),sum_even(6+6),sum_odd(9+7),这种检查点的保存和恢复机制可以为应用程序状态提供exactly-once的一致性,因为所有算子都会保存检查点并恢复其所有状态,这样一来所有的输入流就都会被重置到检查点完成时的位置。

制作快照的时间点

状态恢复的时间点

问题来了,我们能否在任意时刻创建checkpoint保存状态?

试想,当source到5,但是数据未传输到下游奇数累加器,那么,如果这时候建立快照,三处状态分别是5,6,4;那么如果后面从此处恢复,source由于已经是5,所以会直接读6,而事实上此时sum_odd只有(1+3),还没加进5,偶数累加器也不知道5有没有加进来,5就这样被丢失了。

需要的等待所有处理逻辑消费完成source保留状态及之前的数据

image.png

有一个简单的快照制作算法:

  • 暂停处理输入的数据
  • 等待后续所有算子消费当前已经输入的数据
  • 等待上一步处理完后,作业所有的算子复制自己的状态并保存到远端可靠存储
  • 恢复对输入数据的处理

看似挺好,必须得等sum_odd处理完5,并且source也确认过后才会存至远端,继续消费;但是这需要停止作业逻辑,等待可能漫长的状态制作过程,实施场景不太可能

Chandy-Lamport算法

这是一个经典的分布式状态快照制作算法,例子仍然是刚才的奇偶数累加,但是现在两个数据流并行独立地处理各自的数据

用到了checkpoint barrier,这是一种特殊的数据形式,用来把一条流上数据按照不同的checkpoint分开,分界线之前到来的数据导致的状态更改,都会被包含在当前分界线所属的检查点中;而基于分界线之后的数据导致的所有更改,就会包含在之后的检查点中。

image.png

快照制作的开始

每一个source算子都接收到JM发送的checkpoint barrier,标识状态快照制作的开始(图中初始检查点2)

image.png

source算子的处理

各个source保存自己的状态后,向所有连接的下游继续发送checkpoint barrier,同时告知JM自己状态已经制作完成,具体的,source1和source2收到检查点2,会分别向远端存入自己的偏移量蓝3黄4,完后返回一个ack of checkpoint2通知JM快照已经存好Acknowledge completion of task checkpoint2在保存快照时source会暂停发送和处理数据,同时它也会向下游发送带有检查点2的barrier,以广播形式发送(emit),另外可以看出这个过程中sum和sink都在工作

image.png

barrier alignment

barrier继续向下游传递,很有可能没有同时到达,累加任务会等待所有输入分区的barrier到达,barrier已经到达的分区,后续到达的数据会被缓存;而barrier尚未到达的分区,数据会被正常处理。具体的,如下图所示,蓝2通知sum_even,它会等待黄2的barrier达到,此时待处理的数据4来了,会先被缓存buffered record for barrier alignment;黄2的checkpoint2还在路上,barrier尚未到达,在此之前source2发送了待处理数据4,那么sum_even还是可以正常处理sum_event(2+2+4)

  • 算子会等待所有上游的barrier到齐之后,才开始快照的制作
  • 已经制作完成的上游算子会继续处理数据,并不会被下游算子制作快照的过程阻塞

image.png

快照制作和处理数据的解耦

当收到所有输入分区的barrier时,任务就将其状态保存到后端的检查点中,然后将barrier继续向下游发送;barrier对齐之后,sum_even和sum_odd都接收到了两个source发来的barrier,将它们各自的状态8存入checkpoint中,接下来继续向下游sink广播barrier;完后,任务继续正常的数据处理,先处理缓存的数据,蓝4加载进来sum_even 12,黄6加载进来sum_even 18

image.png image.png

checkpoint结束

Sink任务向JobManager确认状态保存到checkpoint完毕;(Sink接收到barrier后先保存状态到checkpoint,然后向JobManager汇报),当所有任务都确认已成功将状态保存到检查点时,检查点就真正完成了。

image.png

checkpoint对作业性能的影响

  1. 解耦了快照制作和数据处理过程,各个算子制作完成状态快照后就可以正常处理数据,不用等下游算子 制作制作完成快照; 
  2. 在快照制作和Barrier Alignment过程中需要暂停处理数据,仍然会增加数据处理延迟; 
  3. 快照保存到远端也有可能极为耗时。

端到端的Exactly-Once实现

Flink本身的Checkpoint机制如何和外部存储结合,实现端到端的不重不漏语义

image.png

  1. Checkpoint能保证每条数据都对各个有状态的算子更新一次,sink输出算子仍然可能下发重复的数据
  2. 严格意义的端到端的exactly-once语义需要特殊的sink算子实现

两个阶段提交协议

在多个节点参与执行的分布式系统中,为了协调每个节点都能同时执行或者回滚某个事务性的操作,引入了一个中心节点来统一处理所有节点的执行逻辑,这个中心节点叫做协作者(coordinate),被中心节点调度的其它业务节点叫做参与者(participant)

image.png

阶段一:预提交阶段

  1. 协作者向所有参与者发送一个commit消息
  2. 每个参与的协作者收到消息后,执行事务,但是不是真正提交
  3. 若事务成功执行完成,发送一个成功的消息(vote yes);执行失败,则发送一个失败的消息(vote no)

阶段二:提交阶段

场景一:若协作者成功接收到所有的参与者vote yes的消息

  1. 协作者向所有参与者发送一个commit消息
  2. 每个收到commit消息的参与者释放执行事务所需的资源,并结束这次事务的执行
  3. 完后,参与者发送一个ack消息给协作者
  4. 协作者受到所有参与者的ack消息后,标识该事务执行完成

场景二:若协作者有收到参与者vote no的消息(或者发生超时等待)

  1. 协作者向所有的参与者发送一个rollback消息
  2. 每个收到rollback消息的参与者回滚事物的执行操作,并释放事务所占资源
  3. 完后,参与者发送一个ack消息给协作者
  4. 协作者收到所有参与者的ack消息,标识该事务成功完成回滚

Flink中的两阶段提交sink

image.png

image.png

image.png

image.png

image.png

  1. 事务开启:在sink task向下游写数据之前,均会开启一个事务,后续所有写数据的操作均在这个事务中执行,事务未提交前,事务写入的数据下游不可读; 
  2. 预提交阶段:JobManager开始下发Checkpoint Barrier,当各个处理逻辑接收到barrier后停止处理后续数据,对当前状态制作快照,此时sink也不在当前事务下继续处理数据(处理后续的数据需要新打开下一个事务)。状态制作成功则向JM成功的消息,失败则发送失败的消息; 
  3. 提交阶段:若JM收到所有预提交成功的消息,则向所有处理逻辑(包括sink)发送可以提交此次事务的消息,sink接收到此 消息后,则完成此次事务的提交,此时下游可以读到这次事务写入的数据;若JM有收到预提交失败的消息,则通知所有处理逻辑回滚这次事务的操作,此时sink则丢弃这次事务提交的数据下。

Flink案例

选择字节内部真实案例场景,介绍Flink如何解决和实现账单计费服务

image.png

执行步骤

  1. 在上次记录的位点之后,从Kafka中读 取固定大小的数据; 
  2. 对该批数据进行去重和聚合计算; 
  3. 处理完成后写入Mysql中,若全部写入成功,则记录下当前读取到的消息的终止位置;若处理或者写入失败,则不记录位点; 
  4. 跳回步骤1

从Kafka中读取账单消息,进行处理后写入到MySQL中

存在的问题:

  1. 非严格意义上的端到端的Exactly-Once 语义: 若该批数据处理完成后,在写入MySQL中发生异常,则存在部分数据写入的情况,下次作业启动后,这部分数据仍然会重复写入; 
  2. 去重能力有限:只能在当前处理的一批数据内进行去重,无法在批与批之间进行去重;

image.png

优势:

  1. 严格意义上的端到端的Exactly-Once语义:下游读到的数据是不丢不重的; 
  2. 增强的去重能力:可以在更长的时间维度 对数据进行去重。