3. 状态存储原理:Flink State Backend详解
状态后端(State Backend),说白了就是 Flink 存状态的地方。你想想看,实时任务跑着跑着,中间结果放哪儿?挂了怎么恢复?这些全靠 State Backend 来管。
我记得刚接触 Flink 那会儿,总觉得状态后端就是个配置项,选哪个都差不多。直到有一次线上任务 OOM 了,我才意识到——选错了 State Backend,代价真不小。
3.1 两大主流 State Backend
Flink 1.13 之后,官方把 State Backend 精简成两种:HashMapStateBackend 和 RocksDBStateBackend。之前的 FsStateBackend、MemoryStateBackend 都 deprecated 了,不用再纠结。
| 对比维度 | HashMapStateBackend | RocksDBStateBackend |
|---|---|---|
| 存储位置 | JVM 堆内存 | 本地磁盘(RocksDB) |
| 状态上限 | 受限于堆内存大小 | 受限于磁盘容量,可达 TB 级 |
| 读写性能 | 纳秒级,极快 | 微秒级,有序列化开销 |
| GC 影响 | 大,状态多时 Full GC 频繁 | 小,数据在堆外 |
| 增量快照 | 不支持 | 支持 |
| 适用场景 | 状态小(< 10GB)、低延迟 | 状态大(> 10GB)、需要高可靠 |
核心原则:状态小于 10GB 且对延迟敏感,选 HashMap;状态大于 10GB 或需要增量 checkpoint,选 RocksDB。
3.2 HashMapStateBackend 深度解析
HashMapStateBackend 把状态存在 TaskManager 的 JVM 堆里。每个算子实例维护一个 HashMap<String, State>,key 是状态名,value 是具体的状态对象。
它的优势很明显——快。读写就是一次 HashMap 的 get/put,没有序列化开销。我在做实时风控项目时,状态量控制在 5GB 以内,用 HashMap 延迟能压到毫秒级。
但坑也不少。我踩过最大的坑是 OOM。一次双十一大促,流量突增,状态膨胀到 20GB,TaskManager 直接挂了。后来我学乖了:
- 给 TaskManager 堆内存留够余量,至少是预估状态的 1.5 倍
- 配合状态 TTL,及时清理过期数据
- 监控
flink_taskmanager_job_operator_current_num_entries指标
注意:HashMapStateBackend 做 checkpoint 时,会把所有状态序列化后发到远端存储。状态越大,checkpoint 耗时越长,容易超时失败。
3.3 RocksDBStateBackend 深度解析
RocksDBStateBackend 用的是嵌入式 Key-Value 存储引擎 RocksDB,数据写在本地的 SST 文件里。它本质上是 LSM-Tree 结构,写性能好,读性能稍逊于 HashMap。
我个人习惯在两种场景下必选 RocksDB:
- 状态超大——比如实时数仓的维表关联,状态轻松上百 GB
- 需要增量 checkpoint——只上传变更的 SST 文件,快照速度极快
不过 RocksDB 也不是银弹。它的读写涉及序列化/反序列化,每次 get/put 都要走一遍 Java ↔ C++ 的跨进程调用。我做过压测,同样逻辑下 RocksDB 的吞吐大约是 HashMap 的 60%-70%。
调优经验:RocksDB 的 write_buffer_size 和 max_write_buffer_number 很关键。我一般设 write_buffer_size 为 64MB,max_write_buffer_number 为 4,这样写放大和 flush 频率比较均衡。
3.4 状态 TTL:别让状态变成"僵尸"
状态 TTL(Time-To-Live)是 Flink 1.8 引入的特性。说白了就是给状态设个保质期,过期自动清理。
我曾经接手过一个实时任务,状态只增不减,跑了三个月,RocksDB 的磁盘占用从 50GB 涨到 500GB。查了半天,发现是没配 TTL。
配置方式很简单,在 StateTtlConfig 里设置:
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.hours(24))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
ValueStateDescriptor<Long> descriptor =
new ValueStateDescriptor<>("count", Types.LONG);
descriptor.enableTimeToLive(ttlConfig);
这里有三个关键参数:
| 参数 | 说明 | 推荐值 |
|---|---|---|
setUpdateType |
什么时候重置 TTL 计时器 | OnCreateAndWrite(读写都重置) |
setStateVisibility |
过期但未清理的状态能否被读到 | NeverReturnExpired(永不返回过期数据) |
setTtlTimeCharacteristic |
使用 ProcessingTime 还是 EventTime | ProcessingTime(默认) |
避坑指南:我曾经把 setStateVisibility 设成了 ReturnExpiredIfNotCleanedUp,结果下游读到过期数据,算出来的指标全偏了。如果你的业务对准确性要求高,一定用 NeverReturnExpired。
3.5 序列化:状态的"翻译官"
状态要存到 RocksDB 或者做 checkpoint,必须序列化成字节数组。Flink 用的是自己的 TypeInformation 体系,不是 Java 原生序列化。
为什么不用 Java 序列化?因为太慢了。Flink 的序列化框架针对常见类型做了优化,比如 IntSerializer 直接写 4 个字节,StringSerializer 用 UTF-8 编码。
我建议你记住几条规则:
- 优先用 Flink 内置类型——Tuple、Pojo、Case Class,序列化效率最高
- 避免 Kryo——虽然 Flink 支持 Kryo 兜底,但性能差一大截
- 自定义 TypeSerializer——如果状态对象复杂,自己实现
TypeSerializer接口,能省 30%-50% 的序列化时间
举个例子,我之前优化过一个实时去重任务,状态存的是 HashSet<String>,默认用 Kryo 序列化。改成自定义 BloomFilterSerializer 后,checkpoint 耗时从 30 秒降到 5 秒。
3.6 一张图看懂 State Backend 选型
下面这张图是我自己总结的选型决策流程,帮你快速判断该用哪个:
3.7 实战建议
说了这么多,最后给几条实在的建议:
- 新项目默认用 RocksDB——除非你确定状态永远小于 10GB
- 一定要配 TTL——不配 TTL 的状态就是定时炸弹
- 监控 checkpoint 耗时——如果 checkpoint 超过 5 分钟,考虑换 RocksDB 或优化序列化
- 测试环境压一把——用生产流量的 1/10 跑 24 小时,看看状态增长曲线
嗯,State Backend 这块内容就这些。理解了存储原理,后面讲状态一致性的时候,你就能明白为什么不同的 Backend 对 Exactly-Once 的支持方式不一样了。