Flink内部Exactly Once
通过checkpoint和状态来保证
checkpoint流程:
Flink在数据流中加入了一个叫barrier的东西(中文名栅栏),barrier在SourceTask处生成,一致到SinkTask,期间所有的Task只要碰到barrier就会触发自身进行快照。barrier的作用就是为了把数据区分开,barrier之前的数据是本次checkpoint之前必须处理完的,barrier之后的数据是本次checkpoint之前不能处理的数据;SourceTask会把数据和barrier一起往下游发送,如果下游某个Task收到barrier,意味着barrier之前的数据已经被处理完了,此时会暂停处理barrier之后的数据,他会做快照。
何为快照?
在checkpoint过程中有一个同步做快照的过程,快照就是把当前的状态信息保存到磁盘,在快照期间是暂停处理barrier之后的数据的,因为如果快照期间Task还在处理数据,可能会导致状态信息还没保存到磁盘,状态就已经变化了,此时写入到磁盘的状态信息就不对了,下次从此处checkpoint重启时,就会发生重复消费。
同步快照的过程不能处理barrier之后的数据,但是为了高可用需要将快照信息上传到HDFS,这个过程是异步的,因为此时处理barrier之后的数据不会影响磁盘上的快照信息。
单并行度下的checkpoint过程
- jobmanager向SourceTaske发送checkpoint,SourceTask会在数据流中安插barrier,安插好之后会把barrier和数据一起发到下游,然后自身做快照,并将快照信息(比如kafka的offset信息(0,100))发送到HDFS上。
- 下游的PV task收到barrier后,也会做快照,把快照信息(比如此时统计的PV值(app1,100))发送到HDFS上。
- 此时的checkpoint就保存了offset100处PV值的统计值为100;下次从这个checkpoint处重启时,获取到(0,100)和(app1,100),再次统计,确保Exactly Once
多并行度下的checkpoint过程
- 所有的Operator运行过程中接收到上游算子发送的barrier后,对自身状态进行一次快照,保存到HDFS上
- barrier对齐问题: 当一个Operator收到上游两个SourceTask的barrier时,由于不能保证两个barrier同时到达,那Operator实例到底什么时候进行快照呢?答案是:做快照前需要等待barrier对齐,就是等待所有输入流的barrier都到达。
- Operator在收到某个输入流的barrier时,就不会处理来自该流的任何数据了,而是把该流的数据放到缓冲区,等待其他流的barrier达到;一旦所有流的barrier都达到后,Operator实例就会把已经处理完成的数据和n个barrier一起发到下游,然后对状态信息做快照,做完快照会先处理缓冲去数据,就可以正常处理输入流的数据了;为了加快下游的checkpoint,会先发送barrier到下游再做快照。
- 如果checkpoint持续时长超过设定的超时时间,会把这次缠身的所有状态数据删除。
- 如果barrier不对齐的话:先到达的数据流会继续处理数据,等到所有barrier达到后,此时Operator记录的状态信息就和先到达数据流的SourceTask中的offset信息不一致,会多处理一些数据,而多处理的这些数据就是等待其他barrier到达时间内的数据。
所以实现barrier对齐就可以实现Exactly Once,如果barrier不对齐就是At Least Once。
实现barrier对齐是要付出代价的。
Flink Web UI 的 Checkpoint 选项卡中可以看到 barrier 对齐的耗时,如果发现耗时比较长,且对 Exactly Once 语义要求不高时,可以考虑使用该优化方案。
端对端的Exactly Once
- 幂等性写入,依赖外部存储介质实现去重,比如HBase,Redis;
做PV统计时,借助Flink内部的状态去统计,不借助外部存储介质,外部介质承担的角色仅仅是提供数据给业务方查询,所以无论下游使用什么形式的 Sink,只要 Sink 端能够按照主键去重,该统计方案就可以保证 Exactly Once。 - TwoPhaseCommitSinkFunction
对于下游有事务的sink,我们需要把checkpoint和写入外部存储介质做强关联,两次checkpoint之间不允许向外部存储介质提交数据,Checkpoint 的时候再向外部存储提交。如果提交成功,则 Checkpoint 成功,提交失败,则 Checkpoint 也失败。这样在下一次 Checkpoint 之前,如果任务失败,也没有重复数据被提交到外部存储。基于这个思想,Flink实现了TwoPhaseCommitSinkFunction。它是基于2PC一致性协议实现的
2pc一致性协议:
1)协调者向参与者发出Vote Request,事务预处理请求,如果所有的参与者都响应了Vote Commit,就进入第二阶段
2)收到所有参与者的Vote Commit,协调者会向所有参与者发出global_commit,然后所有参与者提交本地事务,并返回ack,收到所有ack后,协调者确认所有事务都提交完毕;
只要有一个参与在第一阶段回复Vote_Abort,协调者对所有参与者发出global_rollback,参与者回滚事务并返回ack.
大致流程:
1)所有并行度初始化,会调用开启事务的方法
2)调用invoke()方法处理数据
3)一段时间后做checkpoint,在调用同步快照方法snapshotState()中调用preCommit()方法
4)snapshotState()方法执行完成后,当所有实例都备份完成后就表示ck成功,jobmanager通知ck完成,各实例会调用notifyCheckpointComplete()方法中调用commit()方法
5)期间如果出现其中某个并行度出现故障,JobManager 会停止此任务,向所有的实例发送通知,各实例收到通知后,调用 close 方法。
以写入Mysql为例,实现TwoPhaseCommitSinkFunction
直接上代码
TwoPhaseCommitSinkFunction实现了CheckpointedFunction 和 CheckpointListener 接口
@Slf4j
public class MysqlSink extends TwoPhaseCommitSinkFunction<User, Connection, Void> {
public MysqlSink(){
super(new KryoSerializer<>(Connection.class,new ExecutionConfig()), VoidSerializer.INSTANCE);
}
/**
* 处理数据:
* 开启新的事务后,Flink 开始处理数据,每来一条数据都会调用 invoke 方法,按照业务逻辑将数据添加到本次的事务中
* @param connection
* @param user
* @param context
* @throws Exception
*/
@Override
protected void invoke(Connection connection, User user, Context context) throws Exception {
String sql = "";
PreparedStatement pst = connection.prepareStatement(sql);
pst.setString(1,"");
pst.setString(2,"");
pst.execute();
}
/**
* 开启一个事务:
* 状态初始化的 initializeState 方法内或者
* 每次 Checkpoint 的 snapshotState 方法内都会调用 beginTransaction 方法开启新的事务
*
* @throws Exception
*/
@Override
protected Connection beginTransaction() throws Exception {
Class.forName("com.mysql.jdbc.Driver");
String url = "";
String user = "";
String password = "";
Connection conn = DriverManager.getConnection(url, user, password);
conn.setAutoCommit(false);
return null;
}
/**
* 预提交阶段:
* 等到下一次 Checkpoint 执行 snapshotState 时,会调用 preCommit 方法进行预提交,预提交一般会对事务进行 flush 操作
* @param connection
* @throws Exception
*/
@Override
protected void preCommit(Connection connection) throws Exception {
log.info("start preCommit...");
}
/**
* 提交事务:
* 在各个实例收到jobmanager的checkpoint完成通知后会调用notifyCheckpointComplete()方法
* 在notifyCheckpointComplete()方法中调用commit()方法
* @param connection
*/
@Override
protected void commit(Connection connection) {
try {
connection.commit();
} catch (SQLException e) {
log.error("提交失败!!!");
}
}
/**
* 如果失败了,会调用close()方法
* close()方法中会调用abort()方法回滚
* @param connection
*/
@Override
protected void abort(Connection connection) {
try {
connection.rollback();
} catch (SQLException e) {
log.error("回滚失败!!!");
}
}
}
添加一点想法:因为FlinkKafkaProducer011这个类集成了TwoPhaseCommitSinkFunction,里面很好的实现了端到端的Exactly Once,所以在我们想自己实现端到端的Exactly Once时可以借助这个类,把处理好的数据用过FlinkKafkaProducer011发到一个新的topic,然后再从这个topic获取数据,比如用druid直接连接kafka,这样也能帮我们实现端到端的Exactly Once。