上一篇中提到flink+kafka如何做到任务级顺序保证,而端到端一致性即为实现用户数据目标端与源端的准确一致,当源端数据发生更改时,保证目标端及时、正确、持久的写入更改数据。为实现端到端一致性应在顺序保证的基础上,实现一致性语义exactly once的保证。纵观各底层组件:Debezium、Kafka、Flink构成了端到端一致性中至关重要的每一环,应充分考虑、分析各组件的一致性语义特性的支持。
为实现exactly once语义的一致性,必须提供处理过程的容错性以及处理结果的幂等性。处理过程的容错性是指当计算过程中数据还没插入目标端就发生出现网络异常等情况导致程序重启,会出现数据丢失的问题,为此source端必须可重设数据的读取位置,同时配合Flink内部的 checkpoint机制来解决这个问题。Kafka可以设置读取的offset,每次做 checkpoint 的时候,会把当前消费 Kafka 的 offset、计算结果等写入到状态后端中。任务异常恢复的时候,只需要从最近的一次成功的checkpoint 中拿到 offset 和计算结果,从这个地方接着开始消费和计算即可。幂等性指的是要求sink端从故障恢复时,数据不会因为重设读取位置和重新计算,而被重复写入到外部系统。
flink为不同数据源提供了不同的一致性保证:
AT-MOST-ONCE(最多一次):当任务故障时,最简单的做法是什么都不干,既不恢复丢失的状态,也不重播丢失的数据。At-most-once 语义的含义是最多处理一次事件。
AT-LEAST-ONCE(至少一次):在大多数的真实应用场景,我们希望不丢失事件。这种类型的保障称为 at least-once,意思是所有的事件都得到了处理,而一些事件还可能被处理多次。
EXACTLY-ONCE(精确一次):恰好处理一次是最严格的保证,也是最难实现的。恰好处理一次语义不仅仅意味着没有事件丢失,还意味着针对每一个数据,内部状态仅仅更新一次。
component | Source | Sink | Notes |
---|---|---|---|
Apache Kafka | exactly once | at least once / exactly once | exactly once with transactional producers (v 0.11+) |
AWS Kinesis Streams | exactly once | at least once | |
RabbitMQ | at most once (v 0.10) / exactly once (v 1.0) | ||
Twitter Streaming API | at most once | ||
Google PubSub | at least once | ||
Collections | exactly once | ||
Files | exactly once | exactly once | |
Sockets | at most once | at least once | |
Cassandra | at least once / exactly once | exactly once only for idempotent updates | |
Elasticsearch | at least once | ||
Standard output | at least once | ||
Redis | at least once | ||
JDBC | at least once |
综上,sink端幂等性是实现一致性语义的重要难点。
幂等性
一个相同的操作, 无论重复多少次, 造成的效果都和只操作一次相等;比如更新一个key/Value, 无论你update多少次, 只要key和value不变,那么效果是一样的;再比如更新计数器处理一次消息就计数器+1, 这个操作本身不幂等, 同一个消息被中间件重"发+收"两次就会造成计数器统计两次;而如果我们的消息有id, 那么更新计数器的逻辑修改为, 把处理过的消息的id全记录起来, 接到消息先查重, 然后才更新计数器, 那么这个"更新计数器的逻辑"就变成幂等操作了。
把本不幂等的操作转化为幂等操作是end to end consistency的关键之一。
幂等性问题延申
确定性计算和幂等有些类似, 不过是针对一个计算;相同的input必得到相同的output, 则是一个确定性计算;比如从一个msg里计算出一个key和一个value, 如果对同一个消息运算无数次得到的key和value都相同, 那么这个计算就是确定性的, 而如果key里加上一个当前的时钟的字符串表示, 那么这个计算就不是确定性的, 因为如果重新计算一次这个msg, 得到的是完全不同的key。
注意1: 非确定性计算一般会导致不幂等的操作, 比如我们如果要把上边例子里的key/value存在数据库里, 重复处理多少次同一个msg, 我们就会重复的插入多少条数据(因为key里的时间戳字符串不同。
注意2: 非确定性计算并非必然导致不幂等的操作,比如这个时间戳没有添加在key里而是添加在value里, 且key总是相同的, 那么这个计算还是"非确定性"计算;但是当我们存数据的时候先查重才存key/value, 那么无论我们重复处理多少次同一个msg, 我们也只会成功存入第一个key/Value, 之后的key/Value都会被过滤掉。
缺陷
- 仅在目标端表有主键的情况下适用,适用于HBase、Redis和Cassandra这样的KV数据库;也可以通过给消息设置 SequcenceId 实现去重。
- 无法处理非确定性计算。
- 需要注意的是,也会出现中间状态短暂的不一致,最终结果一致的情景。
预写日志
预写日志(Write-ahead logging,以下简称 WAL)是关系数据库系统中用于提供原子性和持久性(ACID属性中的两个)的一系列技术。在使用WAL的系统中,所有的修改在生效之前都要先写入log文件中。
log文件中通常包括redo和undo信息。这样做的目的可以通过一个例子来说明。假设一个程序在执行某些操作的过程中机器掉电了。在重新启动时,程序可能需要知道当时执行的操作是成功了还是部分成功或者是失败了。如果使用了WAL,程序就可以检查log文件,并对突然掉电时计划执行的操作内容跟实际上执行的操作内容进行比较。在这个比较的基础上,程序就可以决定是撤销已做的操作还是继续完成已做的操作,或者是保持原样。
缺陷
- 微批处理,存在一定延迟。
- 不能保证一批数据全部成功,且按批写入的时候若没有做事务隔离,过程中发生故障恢复后就会导致重复写入。
- 读和写可以并发执行,不会互相阻塞(但是写之间仍然不能并发)。
两阶段提交
对于关系型数据库来说,开启事务即可避免此问题,但对于一个分布式处理系统,如何开启一个分布式事务,或者目标端本身是否支持(分布式)事务成为关键。
一般意义的两阶段提交
两阶段提交(Two-phase Commit,以下简称 2PC)是指在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法。通常,2PC也被称为是一种协议(Protocol)。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,2PC的算法思路可以概括为: 参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈信息决定各参与者是否要提交操作还是中止操作。
要求
外部 sink 系统必须提供事务支持,或者 sink 任务必须能够模拟外部系统上的事务。
Flink中的两阶段提交
Flink 将2PC的逻辑放在 checkpoint 的过程之中,并给出了实现模板类TwoPhaseCommitsinkFunction,Flink 的 JobManager 对应 2PC 的协调者,Operator 实例对应 2PC 的参与者。继承TwoPhaseCommitsinkFunction需要三个类型参数:IN用于指定输入数据的类型;TXN定义了用于故障后事务识别与恢复的事务标识符的类型;CONTEXT用于指定一个可选的自定义上下文对象的类型。继承自TwoPhaseCommitsinkFunction的子类构造函数需要传入两个TypeSerializer,一个用于TXN类型,一个用于CONTEXT类型,TwoPhaseCommitsinkFunction中定义了 5 个抽象方法:beginTransaction()用于开启事务,可从连接池中获取连接,并返回事务句柄;invoke()每来一条数据就会触发一次,当前数据为schema时,可直接执行对应的query语句,当前数据为data时,按照元数据中的变更类型r(全量)、c(增量插入)、u(增量更新)、d(增量删除)、before(变更前数据)、after(数据变更后数据)、db(库名)、table(表名)等信息重组对应的SQL语句,将数据写入到已开启的事务中;preCommit()预提交后的事务将不会再接收数据的写入;commit()提交指定事务;abort()用于终止并回滚指定事务。开发者可实现上述抽象方法自定义实现其对应功能。
protected abstract TXN beginTransaction() throws Exception;
protected abstract void invoke(TXN transaction, IN value, Context context) throws Exception;
protected abstract void preCommit(TXN transaction) throws Exception;
protected abstract void commit(TXN transaction);
protected abstract void abort(TXN transaction);
TwoPhaseCommitsinkFunction中的上述抽象方法与两阶段提交的执行流程如下:
上一个checkpoint完成时,会开启一个新的事务beginTransaction,本次事务中每条数据到来时触发一次 invoke;当前checkpoint 到来时,会对本次事务进行预提交 preCommit。如果 invoke和 preCommit 全部成功了,才表示第一个阶段成功了。如果在第一个阶段中有机器故障,或者 invoke、 preCommit失败,则会触发 abort 方法。在第一个阶段结束时,数据会被写入到外部存储。如果外部存储的事务隔离级别为读已提交时(Read Committed),并不能读取到我们的写入的数据,因为没有执行 commit 操作。
当所有的Operator 实例做完checkpoint,并且都执行完 preCommit 时,会把快照完成的消息发送给 JobManager,JobManager 收到后认为本次 checkpoint 全部完成了,通知所有Operator 实例执行 commit 方法正式提交,此时外部存储就可以读取到我们提交的数据了。
关注作者公众号,一起讨论更多,私信“TwoPhaseCommit”可获得实现案例demo文件
本文由博客一文多发平台 OpenWrite 发布!