参考:
Flink v1.8-State & Fault Tolerance
flink-china/flink-training-course
本参考来源暂未申请授权,如原作者看见有需要可联系(学生一枚,只为学习记录,望谅解)
0. 前言
虽然学了flink有一段时间了,关于time、window等基础概念比较好理解,就不做过多的记录和总结了,但这个state这一块儿还是可以记录一下的。
1. 什么是状态?
先说一句,状态其实就是中间计算结果。
1.1 没有状态的场景
比如在消息队列中有下面这样一个场景
现在要实时计算每个消费者consumer的消息延迟情况,在上图中:
1. 流输入记录
{ timestamp:1367347324
producer:15
consumer:{
consumer1:7,
consumer2:10,
consumer3:12
}
}
2. 延迟计算结果
consumer1:8,
consumer2:5,
consumer3:3
这个很简单,那么在下一个时间戳,再来一条流记录,如果其值是一样的,那么得到的结果也是一样的,这个没问题的对吧,因为就是时间差相减就行了啊。
这样的过程有两个特点:
- 单条输入包含所有需要的信息,即不念过往、不惧将来(=。=)
- 相同的输入,得到相同的输出。
那么问题来了,假如我要实时统计一个网站点击量呢?按照当前的计算方法,那岂不是永远都只有最新的一条点击记录了?这时候就要统计一部分输入了对吧,flink只是流计算框架,又没有存储部分,外接数据库进行存储?
在以前,我们用spark进行微批处理的时候,就是这样的,将当前结果存在mysql中,等过一段时间(例如5s),就再拿5s内的数据进行sum一下再放回去,所以我把这个叫【微批】,而不是【实时】,可以看出来,这其实是pull类型的,是spark主动拉取数据。
很自然的一个想法就是,我能不能把这个临时计算结果记下来,来一条数据,就计算一次,这就是状态!要注意区分的一点是,这是push类型的,并不是说flink拉取一条记录然后计算一下,是数据驱动。状态一直存在那,来数据了,就计算一下。
1.2 有状态计算
还是刚才计算点击量的场景。
1. 流输入记录
"timestamp": "18/Apr/2019:00:00:00",
"url": "/api/a"
}, {
"@timestamp": "18/Apr/2019:00:00:01",
"url": "/api/b"
}, {
"@timestamp": "18/Apr/2019:00:00:02",
"url": "/api/a"
2. 结果
"url": "/api/a"."count": 1
}, {
"url": "/api/b","count": 1
}, {
"url": "/api/a","count": 2
与上面相比,可以看出两个不同的特点:
- 单条记录仅包含部分信息,也就是当前信息,不能代表最终结果
- 相同的输入,得到的输出不同
1.3 有状态计算场景
其实按照上面来看,可以想象,大部分聚合函数,都是要有状态计算的,但是可不仅仅是SQL的聚合还有这些:
- 去重,要知道数据有没有重复,那就要知道以前有没有过这个数据;
- 窗口计算,和聚合计算一样的道理;
- 数据对比,同比/环比,这些都是要和历史数据进行对比的;
- 机器学习/深度学习,包括迭代计算中,每次运行模型的参数,这都是一个状态
简单来说,就是无法光靠自身一条记录,要依赖历史数据的计算,都可以是有状态计算。
2. 两种状态类型(Operator/keyed)
分为两类:Keyed State 和 Operator State。
我理解其实这就和在第一节基础学习说的 Keyed Stream 和 Nonkeyed Stream对应的,想一想第一节中的keyed图,如果不KeyBy的话,整个流就是一个大粗水管,在这上面做窗口计算,就是对所有数据进行计算,但是KeyBy以后,就像分成了很多并行的细水管了,就可以对单个key的流做窗口计算了。
2.1 Keyed State
- 只能用于keyed stream的算子上
- 每个Key对应一个State
在一个Operator实例上可能同时运行多个Key,所以可能访问相应的多个State - (Operator)并行度改变时,Key会被重新分布,此时State跟随Key在实例间迁移
比如开始只有一个实例,两个key及其state都在这个实例上面,后面我要扩容,成了两个实例,那么就将两个key划分到不同的实例上,这时,相应的state就会跟着key一起迁移。 - 通过RuntimeContext访问,其RichFunction我也还没很清楚
- 支持丰富的数据结果
ValueState、ListState、AggregatingState、MapState、ReducingState,其义如其名,
2.2 Operator State
- 可用于所有Operator,主要用于Source,例如FlinkKafkaConsumer
这个结合实际应用场景和上面key state来不难理解,原始数据肯定是各个key的流都混在一起进来的,个人理解这个operator在source阶段,就有点像在Data Source阶段,如果要对数据流进行最简单的预处理和记录,就可以用这个了。
咋说呢,比如我在食堂吃饭,对于食堂阿姨来说,窗口是一直不断来人的,而且每个人要的菜是不一样的,她只能根据同学们点的菜来打,这是根据不同的key来进行不同的操作,但是有一点,无论窗口下一个来的是谁,她都要先做的一件事是先盛饭,盛多少饭这个步骤我就可以看作在source阶段的state。后面再keyby后,进行不同的state管理,也不知道这个理解对不对,还请指教呀。 - 一个operator对应一个state,和并行的算子实例绑定,和数元素中的key无关,每个算子实例中持有所有数据元素中的一部分状态数据。
- 并行度改变时,有多种重新分配方式
operator没有key,所以在并发度改变的时候,需要自己来选择分配方式。
均匀分配:比如本来有3个实例,消费6个partition,这就是每个实例消费2个partition,扩容到6个实例后,均匀分配,就是每个实例分一个partition;
合并后全量分配:有时候,你并不知道新加进来的实例会处理哪一个state,这时候,就把所有的state合并成全量分发给每一个实例,让它们自己选择state - 实现CheckpointedFunction或ListCheckpointed接口
- 支持的数据结构:ListState
3. KeyState
3.1几种keyed state的关系
这里再放一下几种keyed state之间的差异
- ValueState里面的单个值不代表只能是数值,也可以是字符串、list、对象等,就是单个的存在,它的访问接口类似于get和set,update操作就设置该值,value操作就得到该值;
- MapState/ListState操作就类似于java中的map/array数据结构及其操作,与上面value不同的是,如果value里面存了一个list,这时候你要修改其中的一个值(list[0]),在valuestate中就要更新整个list,而在liststate中就可以对这个值单独操作;
- ReducingState和listState的访问接口看起来很像,但是不同的是,比如你要进行聚合计算,累加2分钟内的值,listState中的add就会把这些值都存着,然后一起加,但是reducingState中的add就直接加上去,这样减少内存;
- AggragtingState与上面的reduce区别在哪呢,上面说的都是累加操作,但是如果求平均值呢?在reduce中就只能保留一个数值,也就是保留了总和,却没法保留个数,最后的结果肯定不对。
煮个栗子,进来的数值为[10,20,30],在reduce中的操作就是:
- (10+20)/2 = 15
- (15+30)/2 = 22.5
看到了吧,这样就不对了,在aggregating中的操作就可以保留两个参数,这就是in: - m = (10+20) = 30, n = 2
- m = (30+30) = 60, n = 3
- out = m/n = 60/3 = 20
3.2 keyedstate使用实例
煮一个简单状态机的栗子
啥叫状态机(State Machine):就是状态及其转换条件的组合
比如淘宝买东西,你下单以后,处于【待付款】状态,等你付款以后,就变成了待发货状态,这就是一个状态及其转换条件的组合——状态机
static class StateMachineMapper extends RichFlatMapFunction<Event, Alert> {
private ValueState<State> currentState;
public void open(Configuration conf) {
// 4. 得到当前状态
currentState = getRuntimeContext().getState(new ValueStateDescriptor<>("state", State.class));
}
public void flatMap(Event evt, Collector<Alert> out) throws Exception {
State state = currentState.value();
// 5. 如果原先无这个状态,则先进行初始化
if (state == null) {
state = State.Initial;
}
// 6. 根据事件类型,转换,得到下一个状态
State nextState = state.transition(evt.type());
// 7. 判断下一个状态是否有效
if (nextState == State.InvalidTransition) {
out.collect(new Alert(evt.sourceAddress(), state, evt.type()));
}
// 8. 判断其是否终止
else if (nextState.isTerminal()) {
currentState.clear();
}
// 9. 正常,则将当前状态更新为下一个状态
else {
currentState.update(nextState);
}
}
}
// 1. 事件源
DataStream<Event> events = env.addSource(source);
DataStream<Alert> alerts = events
// 2. 根据sourceAddress划分key
.keyBy(Event::sourceAddress)
// 3. 对每个key执行flatmap操作,也就是上面的操作
.flatMap(new StateMachineMapper());
还是按照淘宝买东西的流程,来讲(复述)一下上面的代码逻辑
- 你第一次点购买,产生了一个下单事件,此时的sourceAddress是空的,所以在第5步,会进行初始化,状态置为【待付款】;
- 第6步,根据你产生的事件类型,得到下一个状态,如果此时来了一个订单取消事件,但是你的订单是已经签收完成了,也就是说该事件和状态非法,7会处理该情况;
- 在【待付款】状态后,你执行了订单取消事件,后续的事件操作也就没有了,此时就是第8步,下一步终止了,就会清除状态;
- 都无异常操作的话,就在第9步,按照你的操作,正常更新订单状态了。
4. 如何管理状态?
上面说了,状态是个好东西,但是要管理,因为计算是在内存中进行的,特别是在分布式环境中,硬件故障是常态,万一宕机了,这个状态不就没了,基于该状态的计算也就错了。
按照flink流计算的场景要求:
- 7*24工作,要保证高可靠性
- 数据不重、不丢,保证数据准确性
- 实时产出,不延迟
既然是在内存中计算,那直接丢在内存中管理呢?是有几个问题的:
- 内存容量有限
- 备份和恢复困难
- 无法横向扩展(内存咋分布式)
按照参考学习资料的说法,理想的状态管理应该有以下几个方面:
- 易用。总不能为了管理个状态,花的时间和精力比计算本身还费劲;
- 高效。这个就是和场景要求对应的了,读写快,恢复快,备份和恢复不影响作业运行,还要方便的横向扩展;
- 可靠。就是保证数据准确性,还要有容错性;
上面说了两种state类型,是按照是否划分为某个key来分类的,按照其state管理方式还可以分为两种:
可以看出来,大部分场景,主要用的就是managed state。
5. 容错机制与故障恢复
5.1 状态如何保存及恢复
- Checkpoint机制
- 定时制作分布式快照,对程序中的状态进行备份
- 发生故障时,将整个作业的checkpoint回滚到最后一次成功checkpoint的状态,然后从那个点开始继续处理
- 是作业粒度,而不是整个集群粒度;
- 最后一次成功checkpoint的状态,失败的不行;
- 必要条件:数据源支持重发
- 不然前面发的消息被作业遗弃了重新开始,数据源却不重发该消息,那不就错了;
- 一致性语义
- 根据barrier是否对齐来判断是哪一种:
- 恰好一次(exactly once)
- 至少一次(at-least once)
关于一致性语义,要前面checkpoint的知识来理解,barrier是来隔开各个checkpoint的,打个比方,如果并发度只有1,那么只要碰到一个barrier,就知道该checkpoint已经完整了,这时肯定是恰好一次。但多并发的情况下,各个snapshot不一定对齐的啊,就根据barrier来对齐。这样才能保证数据不丢,也不重复。
看一段示例代码:
StreamExecutionEnvironment env =
// 获取环境
StreamExecutionEnvironment.getExecutionEnvironment();
// 1000ms制作一个checkpoint
env.enableCheckpointing(1000);
// 恰好一次的模式,也就是要数据对齐
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 每次checkpoint间隔至少500ms
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
// 超时时间60s
env.getCheckpointConfig().setCheckpointTimeout(60000);
// 同时有多少个checkpoint在做
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
// 在作业cancel的时候,是否在外部介质保留checkpoint
env.getCheckpointConfig().enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
因为checkpoint是作业级别的保存点,默认在作业取消的时候会清除。会存在的问题就是,有时候会手动调整并发度,一经调整,就会重启作业,此时如果没有了checkpoint,就无法恢复数据,当然,这种情况还有一个方法——savepoint
5.2 状态存储方式
- MemoryStateBackend
- 构造方法:MemoryStateBackend(int maxStateSize, boolean Asy…)
- 存储方式:
- state:task manage内存
- checkpoint:jobManage内存
- 容量限制(虽然参数可调):
- 单个state的容量:5M,总state < akka.framesize
- checkpoint < job manage 的内存容量
- 使用场景:
- 本地测试:几乎不需要状态的场景,例如etl,或者job manage不容易/无所谓挂的情况,因为checkpoint是存在job manage的内存,一旦挂了就没了
- FsStateBackend
- 构造方法:FsStateBackend(URI XXX, boolean Asy…)
- 存储方式:
- state:task manage内存
- checkpoint:外部文件系统(本地或者HDFS)
- 容量限制(虽然参数可调):
- state:不超过task managed的内存总容量
- checkpoint:不超过文件系统大小
- 使用场景:
- 常规用法,分钟级窗口聚合;HA
- RocksDBStateBackend
-
构造方法:RocksDBStateBackend(URI XXX, boolean enableIncremental…)
-
存储方式:
- state:task manage上的KV数据库(内存+磁盘,内存溢写到磁盘)
- checkpoint:外部文件系统(本地或者HDFS)
-
容量限制(虽然参数可调):
- 单task manage上的state总量不超过其内存+磁盘(也就是单个节点的所有存储量不能撑爆就行)
- 单个key的state大小不超过2G
- 总大小不超过文件系统大小
-
使用场景:
- 超大状态的作业,天级窗口聚合;HA;对状态读写性能不高的场合(毕竟可能经过磁盘)
-
有一点是,前面两个的2参数都是是否异步,RocksDBStateBackend不支持该选项,但是是目前唯一支持增量保存state的方式!
x. Q&A
-
实时展示最近7日的均值
实时展示的黑科技:queriable state,可以在流计算之外,查看流数据的状态。目前在beta版 -
如果使用了savepoint重启了,checkpoint是否还会重启?
这可以理解为两个不同的应用场景,一般savepoint是人工操作,重启整个环境之类的,需要人为选择从哪一个savepoint启动;
而checkpoint更倾向于job环境还在,其中某个组件出故障了,类似于常态的硬件故障,这时自动恢复,不需要人为参与的。