Flink通过快照机制和Barrier来实现一致性的保证,当任务中途crash或者cancel之后,可以通过checkpoing或者savepoint来进行恢复,实现数据流的重放。从而让任务达到一致性的效果,这种一致性需要开启exactly_once模式之后才行。需要记住的是这边的Flink exactly_once只是说在Flink内部是exactly_once的,并不能保证与外部存储交互时的exactly_once,如果要实现外部存储连接后的exactly_once,需要进行做一些特殊的处理。Flink定义的checkpiont支持两种模式(CheckpointingMode):
EXACTLY ONCE
该模式意味着系统在进行恢复时,每条记录将在Operator状态中只被重现/重放一次。例如在一段数据流中,不管该系统crash或者重启了多少次,该统计结果将总是跟流中的元素的真实个数一致。
当然EXACTLY_ONCE并不是说毫无确定,相比较AT_LEAST_ONCE,整体的处理速度会相对比较慢,因为在开启EXACTLY_ONCE后,为了保证一致性开启了数据对齐,从而影响了一些性能。
AT LEAST ONCE
该模式意味着系统将以一种更加简单的方式来对operator的状态进行快照,系统crash或者cancel后恢复时,operator的状态中有一些记录可能会被重放多次。
例如,以上面的例子讲说,失败后恢复时,统计值将等于或者大于流中元素的真实值。这种模式因为不需要对齐所有对延迟产生的影响很小,处理速度也更加快速,通常应用于接收低延时并且能够容忍重复消息的场景。
虽然上面讲到了一致性的保证是通过快照和Brrier机制来实现的,那他们具体是如何实现的呢?阅读中可以通过带入以下几点来进行考虑:
CHECKPOINT
快照记录了系统当前各个task/Operator的状态,这些状态保存了正常处理的元素。这些快照将被定期的删除和更新,系统出现crash后,进行恢复时就会从这些快照中读取数据,恢复crash之前的状态,那么该如何理解状态(STATE)呢?
STATE
State 可以理解为某task/operator在某时刻的一个中间结果,比如在flatmap中在这段时刻处理的数据,State可以被记录,在系统失败的情况可以进行恢复。STATE主要有两种类型operator state和keyed state。
OPERATOR STATE和KEYED STATE
Operator state是一个与key无关,并且在全局中唯一绑定到特定的operator中的state,比如有source或者map算子,如果需要保存这些operator的状态,就可以在这些operator添加状态的处理机制,具体可以看下面的例子。
Operator state只有一种数据结构ListState
Keyed State:
keyed state的数据结构:
CHECKPOINT实现例子
这是operator state实现的例子
public class BufferingSink implements SinkFunction>,CheckpointedFunction {
private final int threshold;
private transient ListState> checkpointedState;
private List> bufferedElements;
public BufferingSink(int threshold) {
this.threshold = threshold;
this.bufferedElements = new ArrayList>();
}
@Override
public void invoke(Tuple2 value, Context context) throws Exception {
bufferedElements.add(value);
if(bufferedElements.size() == threshold){
for(Tuple2 element:bufferedElements){
//send it to the sink
}
bufferedElements.clear();
}
}
@Override
public void snapshotState(FunctionSnapshotContext functionSnapshotContext) throws Exception {
/**定期实现checkpoint*/
checkpointedState.clear();
for(Tuple2 element:bufferedElements){
checkpointedState.add(element);
}
}
/**恢复初始化的时候从保存的快照中获取数据,用于恢复到crash之前的状态*/
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
ListStateDescriptor> descriptor = new ListStateDescriptor>
("buffered-elements",TypeInformation.of(new TypeHint>() {
}));
checkpointedState = context.getOperatorStateStore().getListState(descriptor);
if(context.isRestored()){
for(Tuple2 element:checkpointedState.get()){
bufferedElements.add(element);
}
}
}
}
这是keyed state实现的例子:
public static class StateMachineMapper extends RichFlatMapFunction {
/** 为当前key创建一个keyed state. */
private ValueState currentState;
@Override
public void open(Configuration conf) {
// 启动时从checkpoint中加载保存的state
currentState = getRuntimeContext().getState(
new ValueStateDescriptor<>("state", State.class));
}
@Override
public void flatMap(Event evt, Collector out) throws Exception {
// 获取当前key的state值,如果没有则初始化
State state = currentState.value();
if (state == null) {
state = State.Initial;
}
// 根据给定的事件询问状态机我们应该进入什么状态
State nextState = state.transition(evt.type());
if (nextState == State.InvalidTransition) {
out.collect(new Alert(evt.sourceAddress(), state, evt.type()));
} else if (nextState.isTerminal()) {
currentState.clear();
} else {
currentState.update(nextState);
}
}
}
BARRIER
相对于checkpoint并没有需要很高深的理解,因为这种机制在spark,hdfs等需要高容错机制的系统都会涉及,Flink的高效一致性保证的核心概念之一是Barrier,这个Barrier是用来解决上面提到的问题2(什么时候触发快照)。它就是一个屏障,一个关卡,用来把无界流的流式数据变为有界流,每隔一段时间处理一段有界流,当开启EXACTLY_ONCE后,Barrier会被注入到输入流中随着数据一起向下流动,当所有的operator得到是Barrier类型的数据流时就会进行实现SNAPSHOT,并且Barriers永远不会超过记录,数据流严格有序。每个Barrier都带有一个long型的checkpointId,当operator执行完SNAPSHOT后,会ack当前operator的checkpointId给JobManager,JobManager收集齐所有的当前checkpointId时,才会放开下一批的数据进行处理。
Barrier在数据输入流源处被注入并行数据流中。SNAPSHOTn的Barriers被插入的位置(Sn)是SNAPSHOT所包含的数据在数据源中最大位置,例如在kafka中,此位置将是分区中最后一条记录的偏移量。将该位置Sn报告给checkpoint协调器。 然后Barrier向下游动。当一个中间operator从其他所有输入流中受到SNAPSHOTn的barriers时,他会成为SNAPSHOTn发出barriers进入其所有输出流中。一旦sink操作算子(流失DAG的末端)从其所有输入流接收到barrier n,它就向checkpoint协调器确认SNAPSHOTn完成。在所有sink确认快照后,意味着快照已经完成。 一旦完成SNAPSHOTn,job将永远不再向数据源请求sn之前的数据,因为此时这些记录(及其后续记录)将已经通过整个数据流拓扑,也即是已经被处理结束啦。
接收多个输入流的运算符需要基于快照barrier对齐输入流。上图说明了这一点:
讲述完Barrier可以看下图,checkpointing的过程:
算子在他们从输入流接收到所有SNAPSHOT障碍时,以及在向其输出流发出障碍之前对其状态进行SNAPSHOT。此时,将根据障碍之前的记录对状态进行所有更新,并且在应用障碍之后不依赖于记录的更新。由于SNAPSHOT的状态可能很大,因此它存储在可配置的状态后台中。默认情况下,这是JobManager的内存,但对于生产使用,应配置分布式可靠存储(例如HDFS)。在存储状态之后,算子确认检查点,将SNAPSHOT屏障发送到输出流中,然后继续。
生成的SNAPSHOT现在包含:
对于每个并行流数据源,启动SNAPSHOT时流中的偏移/位置
对于每个 算子,指向作为SNAPSHOT的一部分存储的状态的指针
BARRIER核心代码解析
上面讲到Flink的一致性保证的核心之一就是Barrier,下面会对barrier的核心代码BarrierBuffer进行讲解,BarrierBuffer用于提供EXACTLY_ONCE一致性保证,其作用是:它将以barrier阻塞输入知道所有的输入都接收到基于某个检查点的barrier,也就是之前讲到的对齐,为了避免反压输入流(这可能导致分布式死锁),BarrierBuffer将从被阻塞的channel中持续地接收buffer并在内部存储它们,知道阻塞被解除。
CheckpointCoordinator
在讲BarrierBuffer之前,可以先看下checkpoint是什么时候触发创建的,可以从CheckpointCoordinator这个Checkpoint协调器的startCheckpointScheduler()这个方法看出,在该方法创建了一个线程用来定时发送checkpoint的方法。
public void startCheckpointScheduler() {
synchronized (lock) {
if (shutdown) {
throw new IllegalArgumentException("Checkpoint coordinator is shut down");
}
// make sure all prior timers are cancelled
stopCheckpointScheduler();
periodicScheduling = true;
long initialDelay = ThreadLocalRandom.current().nextLong(
minPauseBetweenCheckpointsNanos / 1_000_000L, baseInterval + 1L);
//按照baseInterval定时启动触发器
currentPeriodicTrigger = timer.scheduleAtFixedRate(
new ScheduledTrigger(), initialDelay, baseInterval, TimeUnit.MILLISECONDS);
}
}
private final class ScheduledTrigger implements Runnable {
@Override
public void run() {
try {
//触发checkpoint
triggerCheckpoint(System.currentTimeMillis(), true);
}
catch (Exception e) {
LOG.error("Exception while triggering checkpoint for job {}.", job, e);
}
}
}
//在triggerCheckpoint方法中会调用所有具有checkpoint的Execution方法triggerCheckpoint
// send the messages to the tasks that trigger their checkpoint
for (Execution execution: executions) {
execution.triggerCheckpoint(checkpointID, timestamp, checkpointOptions);
}
BarrierBuffer
介绍了checkpoint的触发方式后,再回来看BarrierBuffer类,该类有几个核心的方法,下面将进行一一解释。 getNextNonBlocked getNextNonBlocked方法用于获取待operator处理的下一条(非阻塞)的记录。该方法以多种机制阻塞当前调用上下文,直到获取到下一个非阻塞的记录。
@Override
public BufferOrEvent getNextNonBlocked() throws Exception {
while (true) {
//获得下一个待缓存的buffer或者barrier事件
// process buffered BufferOrEvents before grabbing new ones
Optional next;
//如果当前的缓冲区为null,则从输入端获得
if (currentBuffered == null) {
next = inputGate.getNextBufferOrEvent();
}
//如果缓冲区不为空,则从缓冲区中获得数据
else {
next = Optional.ofNullable(currentBuffered.getNext());
//如果缓冲区获取的数据不存在,则表示缓冲区中已经没有更多地数据了
if (!next.isPresent()) {
//清空当前缓冲区,获取已经新的缓冲区并打开它
completeBufferedSequence();
//递归调用,处理下一条数据
return getNextNonBlocked();
}
}
//获取到一条记录,表示该数据存在
if (!next.isPresent()) {
//输入流的结束。stream继续处理缓冲数据
if (!endOfStream) {
// end of input stream. stream continues with the buffered data
endOfStream = true;
releaseBlocksAndResetBarriers();
return getNextNonBlocked();
} else {
// final end of both input and buffered data
return null;
}
}
BufferOrEvent bufferOrEvent = next.get();
//如果获取到的记录所在的channel已经处于阻塞状态,则该记录会被加入缓冲区
if (isBlocked(bufferOrEvent.getChannelIndex())) {
// if the channel is blocked, we just store the BufferOrEvent
bufferBlocker.add(bufferOrEvent);
checkSizeLimit();
}
//如果该记录是一个正常的记录,而不是一个barrier事件,则直接返回
else if (bufferOrEvent.isBuffer()) {
return bufferOrEvent;
}
//如果是一个barrier事件
else if (bufferOrEvent.getEvent().getClass() == CheckpointBarrier.class) {
//并且当前流还未处于结束桩体,则处理该barrier
if (!endOfStream) {
// process barriers only if there is a chance of the checkpoint completing
processBarrier((CheckpointBarrier) bufferOrEvent.getEvent(), bufferOrEvent.getChannelIndex());
}
}
//它发出信号,表示应该取消某个检查点。需要取消该检查点的任何正在进行的对齐,并恢复常规处理。
else if (bufferOrEvent.getEvent().getClass() == CancelCheckpointMarker.class) {
processCancellationBarrier((CancelCheckpointMarker) bufferOrEvent.getEvent());
} else {
//如果它是一个EndOfPartitionEvent,表示当前已经到达分区末尾
if (bufferOrEvent.getEvent().getClass() == EndOfPartitionEvent.class) {
processEndOfPartition();
}
return bufferOrEvent;
}
}
}
private void processEndOfPartition() throws Exception {
//以关闭的channel计数器加一
numClosedChannels++;
//此时已经没有机会完成该检查点,则解除阻塞
if (numBarriersReceived > 0) {
// let the task know we skip a checkpoint
notifyAbort(currentCheckpointId, new InputEndOfStreamException());
// no chance to complete this checkpoint
releaseBlocksAndResetBarriers();
}
}
当checkpoint完成之后会调用releaseBlocksAndResetBarriers()方法,该方法释放所有通道上的块并且重置barrier计数,确保下一次使用的时候能够正常使用。
/** * Releases the blocks on all channels and resets the barrier count. * Makes sure the just written data is the next to be consumed. * 释放所有通道上的块并重置屏障计数。确保下一个使用的是刚刚写好的数据。 */
private void releaseBlocksAndResetBarriers() throws IOException {
LOG.debug("{}: End of stream alignment, feeding buffered data back.",
inputGate.getOwningTaskName());
for (int i = 0; i < blockedChannels.length; i++) {
//将所有channel的阻塞标志设置为false
blockedChannels[i] = false;
}
//如果当前的缓冲区中数据为空
if (currentBuffered == null) {
// common case: no more buffered data
//初始化新的缓冲区读写器
currentBuffered = bufferBlocker.rollOverReusingResources();
//打开缓冲区读写器
if (currentBuffered != null) {
currentBuffered.open();
}
}
else {
// uncommon case: buffered data pending
// push back the pending data, if we have any
LOG.debug("{}: Checkpoint skipped via buffered data:" +
"Pushing back current alignment buffers and feeding back new alignment data first.",
inputGate.getOwningTaskName());
// since we did not fully drain the previous sequence, we need to allocate a new buffer for this one
//缓冲区中还有数据,则初始化一块新的存储空间来存储新的缓冲数据
BufferOrEventSequence bufferedNow = bufferBlocker.rollOverWithoutReusingResources();
if (bufferedNow != null) {
//打开新的缓冲区读写器
bufferedNow.open();
//将当前没有处理完的数据加入队列中
queuedBuffered.addFirst(currentBuffered);
numQueuedBytes += currentBuffered.size();
//将新开辟的缓冲区读写器置为新的当前缓冲区。
currentBuffered = bufferedNow;
}
}
if (LOG.isDebugEnabled()) {
LOG.debug("{}: Size of buffered data: {} bytes",
inputGate.getOwningTaskName(),
currentBuffered == null ? 0L : currentBuffered.size());
}
// the next barrier that comes must assume it is the first
// 将接受到的barrier累加值重置为0
numBarriersReceived = 0;
if (startOfAlignmentTimestamp > 0) {
latestAlignmentDurationNanos = System.nanoTime() - startOfAlignmentTimestamp;
startOfAlignmentTimestamp = 0;
}
}
还有一个很重要的方法processBarrier()方法,用来处理当接收一个Barrier事件时的具体处理方法。
private void processBarrier(CheckpointBarrier receivedBarrier, int channelIndex) throws Exception {
final long barrierId = receivedBarrier.getId();
// 单通道情况下的快速路径
if (totalNumberOfInputChannels == 1) {
if (barrierId > currentCheckpointId) {
// new checkpoint
currentCheckpointId = barrierId;
notifyCheckpoint(receivedBarrier);
}
return;
}
// -- general code path for multiple input channels --
//获取接收到的barrierId
//接收到的barrier数目>0,说明当前正在处理某个检查点的过程中
if (numBarriersReceived > 0) {
// this is only true if some alignment is already progress and was not canceled
//当前某个检查点的某个后续的barrierId
if (barrierId == currentCheckpointId) {
// regular case 处理barrier
onBarrier(channelIndex);
}
//barrier Id>当前检查点
else if (barrierId > currentCheckpointId) {
// we did not complete the current checkpoint, another started before
//我们没有完成当前的检查点,之前又开始了一个
LOG.warn("{}: Received checkpoint barrier for checkpoint {} before completing current checkpoint {}. " +
"Skipping current checkpoint.",
inputGate.getOwningTaskName(),
barrierId,
currentCheckpointId);
// let the task know we are not completing this
//让任务知道我们没有完成这项任务
notifyAbort(currentCheckpointId, new CheckpointDeclineSubsumedException(barrierId));
// abort the current checkpoint
//中止当前检查点,当前检查点已经没有机会完成了,则解除阻塞
releaseBlocksAndResetBarriers();
// begin a the new checkpoint
beginNewAlignment(barrierId, channelIndex);
}
else {
// ignore trailing barrier from an earlier checkpoint (obsolete now)
return;
}
}
else if (barrierId > currentCheckpointId) {
// 说明这是一个新检查点的初始barrier
beginNewAlignment(barrierId, channelIndex);
}
else {
//忽略之前(跳过的)检查点的未处理的barrier
// either the current checkpoint was canceled (numBarriers == 0) or
// this barrier is from an old subsumed checkpoint
return;
}
//检查我们是否有所有的障碍——因为被取消的检查点总是没有障碍
//这只能发生在一个未取消的检查点上
// check if we have all barriers - since canceled checkpoints always have zero barriers
// this can only happen on a non canceled checkpoint
if (numBarriersReceived + numClosedChannels == totalNumberOfInputChannels) {
// actually trigger checkpoint
if (LOG.isDebugEnabled()) {
LOG.debug("{}: Received all barriers, triggering checkpoint {} at {}.",
inputGate.getOwningTaskName(),
receivedBarrier.getId(),
receivedBarrier.getTimestamp());
}
releaseBlocksAndResetBarriers();
notifyCheckpoint(receivedBarrier);
}
}
BarrierTracker
在AT_LEAST_ONCE的模式下,调用BarrierTracker类中的getNextNonBlocked()方法,从该方法可以看出,Barrier不会进行对齐,连续不断的从inputGate中getNextBufferOrEvent().
@Override
public BufferOrEvent getNextNonBlocked() throws Exception {
while (true) {
Optional next = inputGate.getNextBufferOrEvent();
if (!next.isPresent()) {
// buffer or input exhausted
return null;
}
BufferOrEvent bufferOrEvent = next.get();
if (bufferOrEvent.isBuffer()) {
return bufferOrEvent;
}
else if (bufferOrEvent.getEvent().getClass() == CheckpointBarrier.class) {
processBarrier((CheckpointBarrier) bufferOrEvent.getEvent(), bufferOrEvent.getChannelIndex());
}
else if (bufferOrEvent.getEvent().getClass() == CancelCheckpointMarker.class) {
processCheckpointAbortBarrier((CancelCheckpointMarker) bufferOrEvent.getEvent(), bufferOrEvent.getChannelIndex());
}
else {
// some other event
return bufferOrEvent;
}
}
}