状态 + Checkpoint = 完整的流式计算“记忆”
类比:状态是大脑的短期记忆,Checkpoint是写日记存档,Flink是有健忘症的天才
| 组件 | 主要功能 | 局限性 | 解决方案 |
|---|---|---|---|
| 状态 | 存储计算中间结果(如UV统计的用户ID集合) | 内存存储,存在丢失风险(任务失败/重启) | 配合Checkpoint机制定期持久化 |
| Checkpoint | 将状态快照定期保存至持久化存储 | 无法识别已保存状态及最新状态值 | 从状态读取并进行序列化处理 |
一、两者如何配合工作?
-
场景设定
统计商品页面(page_001)最近5分钟的独立访客数(UV),滑动步长1分钟。- 数据流(时间顺序):
Time 10:00:00 - user_001 访问 page_001
Time 10:00:10 - user_002 访问 page_001
Time 10:00:20 - user_001 再次访问 page_001
Time 10:00:30 - user_003 访问 page_001
Time 10:00:40 - user_004 访问 page_001
Time 10:01:00 - user_005 访问 page_001
...
- 窗口定义:
滑动窗口 :5分钟长度,1分钟滑动步长
当前窗口:[10:00:00, 10:05:00)
数据到达时,Flink会自动判断属于哪个窗口
- 完整处理流程(含状态变化)
- 初始状态(10:00:00)
状态:page_001 → HashSet{} (空)
Checkpoint:无
- 处理第一条数据(10:00:00)
输入:user_001 访问 page_001
操作:
1. 读取状态:HashSet{}
2. 添加user_001 → HashSet{user_001}
3. 更新状态:page_001 → HashSet{user_001}
4. 窗口[10:00:00, 10:05:00)的UV=1
当前状态:page_001 → HashSet{user_001}
- 处理第二条数据(10:00:10)
输入:user_002 访问 page_001
操作:
1. 读取状态:HashSet{user_001}
2. 添加user_002 → HashSet{user_001, user_002}
3. 更新状态:page_001 → HashSet{user_001, user_002}
4. 窗口[10:00:00, 10:05:00)的UV=2
当前状态:page_001 → HashSet{user_001, user_002}
- 处理第三条数据(10:00:20)
输入:user_001 再次访问 page_001
操作:
1. 读取状态:HashSet{user_001, user_002}
2. 添加user_001 → HashSet{user_001, user_002} (已存在,无变化)
3. 更新状态:page_001 → HashSet{user_001, user_002}
4. 窗口[10:00:00, 10:05:00)的UV=2 (保持不变)
当前状态:page_001 → HashSet{user_001, user_002}
- Checkpoint触发(10:00:30)
这是关键时刻!
Checkpoint过程:
1. 停止新数据流入(暂停处理)
2. 序列化当前状态:
- Kafka消费位置:Offset=3 (已处理3条消息)
- page_001的状态:HashSet{user_001, user_002} → 序列化为字节数组
3. 写入持久化存储(HDFS):
/checkpoints/chk-001/
├── metadata (Offset=3, checkpoint时间=10:00:30)
└── states/operator_state (HashSet{user_001, user_002})
4. Checkpoint完成,恢复数据流入
当前状态:page_001 → HashSet{user_001, user_002}
Checkpoint保存:chk-001
- 继续处理数据(10:00:30-10:00:40)
输入:user_003 访问 page_001
操作:
1. 读取状态:HashSet{user_001, user_002}
2. 添加user_003 → HashSet{user_001, user_002, user_003}
3. 更新状态:page_001 → HashSet{user_001, user_002, user_003}
4. 窗口[10:00:00, 10:05:00)的UV=3
当前状态:page_001 → HashSet{user_001, user_002, user_003}
输入:user_004 访问 page_001
操作:
1. 读取状态:HashSet{user_001, user_002, user_003}
2. 添加user_004 → HashSet{user_001, user_002, user_003, user_004}
3. 更新状态:page_001 → HashSet{user_001, user_002, user_003, user_004}
4. 窗口[10:00:00, 10:05:00)的UV=4
当前状态:page_001 → HashSet{user_001, user_002, user_003, user_004}
- 任务失败(10:00:50)
突然情况:TaskManager节点宕机
内存状态丢失:HashSet{user_001, user_002, user_003, user_004} 消失!
- 恢复过程(状态+Checkpoint协作)
- 任务重启(10:01:00)
重启操作:
1. JobManager检测到任务失败
2. 查找最近的Checkpoint:chk-001 (10:00:30)
3. 通知所有TaskManager从Checkpoint恢复
- 状态恢复
恢复过程:
1. 从HDFS加载chk-001
2. 反序列化状态:
- Kafka消费位置:Offset=3
- page_001状态:HashSet{user_001, user_002}
3. 重新设置状态:page_001 → HashSet{user_001, user_002}
- 重新消费数据
关键点:KafkaSource从Offset=3的下一个位置开始消费
即从第4条消息(Offset=3)开始重新处理
消费队列:
Offset=3 → user_003 访问 page_001 (10:00:30)
Offset=4 → user_004 访问 page_001 (10:00:40)
Offset=5 → user_005 访问 page_001 (10:01:00)
- 重新处理(保证Exactly-Once)
重新处理Offset=3 (user_003):
1. 读取恢复状态:HashSet{user_001, user_002}
2. 添加user_003 → HashSet{user_001, user_002, user_003}
3. 更新状态:page_001 → HashSet{user_001, user_002, user_003}
4. UV=3
重新处理Offset=4 (user_004):
1. 读取状态:HashSet{user_001, user_002, user_003}
2. 添加user_004 → HashSet{user_001, user_002, user_001, user_004}
3. 更新状态:page_001 → HashSet{user_001, user_002, user_003, user_004}
4. UV=4
最终状态:page_001 → HashSet{user_001, user_002, user_003, user_004}
与失败前完全一致!
- 结论
- 状态是"实时记忆":处理过程中持续更新,存储计算中间结果。
- Checkpoint是"定期存档":固定时间点冻结并保存状态快照。
- 协同工作模式:
- 正常处理:状态实时更新 → Checkpoint定期保存
- 失败恢复:Checkpoint加载存档 → 重新处理Checkpoint后的数据
- Exactly-Once保证:
- 状态恢复确保计算进度正确
- Kafka Offset恢复确保数据不丢不重
- 最终效果:即使任务在10:00:50失败,也能通过10:00:30的Checkpoint恢复到完全一致的计算结果,实现了端到端的精确一次处理。
二、窗口到底是什么
Flink窗口机制:流式计算的"时间切片器"
1、核心本质
流是 无限 的,但聚合计算必须在有限数据集上做。窗口就是把无限流切成一个个"有限数据块"的机制。
没有窗口,你就只能做"从作业启动开始的无限累加,这在真实业务中几乎没用。
2、窗口 类 型
- 按时间类型分
| 类型 | 时间基准 | 适用场景 | 代码示例 |
|---|---|---|---|
| 处理时间窗口 | Flink系统时钟 | 实时监控、延迟敏感 | .window(TumblingProcessingTimeWindows.of(Time.minutes(5))) |
| 事件时间窗口 | 数据自带时间戳 | 准确统计、金融交易 | .assignTimestampsAndWatermarks(…).window(TumblingEventTimeWindows.of(…)) |
| 摄入时间窗口 | Source摄入时间 | 简单场景 | 较少使用 |
- 按窗口行为分
| 类型 | 窗口划分 | 特点 | 可视化示例 |
|---|---|---|---|
| 滚动窗口 | 不重叠 | 简单,数据只属于一个窗口 | [1,2,3,4,5] [6,7,8,9,10] |
| 滑动窗口 | 部分重叠 | 计算更平滑 | [1,2,3,4,5] [2,3,4,5,6] |
| 会话窗口 | 活动间隔 | 按用户活动自动划分 | [1,2] 间隔 [3,4,5] |
- 按数据驱动分
类型 驱动方式 窗口大小 示例
时间窗口 时间驱动 固定时间长度 5分钟窗口
计数窗口 数据量驱动 固定数据条数 每1000条一个窗口
全局窗口 自定义触发 无限 需要自定义触发器
3、通俗易懂
窗口(Window) 是将无限的数据流切割成有限的数据块,以便进行聚合计算的机制。它就像时间切片器,把连续的时间流切成一段段可处理的时间段。
类比:
无限流:一条永不停歇的河流
窗口:水桶,定期从河流中舀取一段水进行分析
计算结果:分析每桶水的质量(如UV、PV、平均温度等)
4、窗口的完整生命周期
- 窗口创建与分配
// 数据到达时,Flink判断它属于哪些窗口
Collection<W> windows = windowAssigner.assignWindows(
element,
elementTimestamp,
context
);
// 例如:5分钟滑动窗口,1分钟步长,数据可能属于5个不同窗口
- 状态存储与更新
// 为每个窗口维护独立状态
for (W window : windows) {
// 获取或创建该窗口的状态
S state = windowState.get(window);
// 更新状态(如添加用户ID到HashSet)
state.update(element);
// 保存状态
windowState.put(window, state);
}
- 窗口触发计算
// 当触发条件满足时,计算窗口结果
if (trigger.onElement(element, timestamp, window, ctx)) {
// 触发窗口计算
Iterable<IN> windowData = windowState.get(window);
OUT result = windowFunction.apply(window, windowData);
output.collect(result);
}
- 窗口清理
// 窗口过期后,清理其状态
if (window.maxTimestamp() + allowedLateness < currentWatermark) {
windowState.remove(window); // 清理状态,释放内存
}
5、窗口的三大核心组件
- 窗口分配器(Window Assigner)
作用:决定数据属于哪个/哪些窗口
// 内置分配器
TumblingEventTimeWindows.of(Time.minutes(5)) // 5分钟滚动窗口
SlidingProcessingTimeWindows.of(Time.minutes(5), Time.minutes(1)) // 5分钟滑动,1分钟步长
EventTimeSessionWindows.withGap(Time.minutes(30)) // 30分钟会话窗口
- 触发器(Trigger)
作用:决定何时触发窗口计算
// 内置触发器
ProcessingTimeTrigger.create() // 处理时间触发
EventTimeTrigger.create() // 事件时间触发
CountTrigger.of(100) // 每100条触发
PurgingTrigger.of(ProcessingTimeTrigger.create()) // 触发后清理状态
- 驱逐器(Evictor)
作用:窗口计算前/后移除部分数据
CountEvictor.of(1000) // 保留最近1000条
TimeEvictor.of(Time.seconds(10)) // 保留最近10秒
DeltaEvictor.of(threshold, deltaFunction) // 基于阈值
窗口在Flink架构中的位置
窗口是Flink流批一体的核心,它将无限流转化为有限批处理,同时保持了 流处理 的实时性。正确理解和使用窗口,是掌握Flink流式计算的关键。
6、窗口的使用
窗口类型的三个维度
Flink窗口实际上是三个独立维度的交叉组合:
窗口 = 时间类型(处理/事件/摄入) × 窗口行为(滚动/滑动/会话) × 驱动方式(时间/计数/全局)