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:

  1. 状态超大——比如实时数仓的维表关联,状态轻松上百 GB
  2. 需要增量 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 选型

下面这张图是我自己总结的选型决策流程,帮你快速判断该用哪个:

State Backend 选型决策流程 开始选型 状态大小 < 10GB? 是 否 延迟敏感? RocksDBStateBackend 是 否 HashMapStateBackend RocksDBStateBackend 配置 TTL + 选择序列化方式 说明:状态大小以 checkpoint 实际大小为准,不是堆内存占用。延迟敏感指单次状态读写 < 1ms 的业务。 如果状态在 10GB 边缘,建议用 RocksDB + 增量 checkpoint,更稳妥。

3.7 实战建议

说了这么多,最后给几条实在的建议:

  • 新项目默认用 RocksDB——除非你确定状态永远小于 10GB
  • 一定要配 TTL——不配 TTL 的状态就是定时炸弹
  • 监控 checkpoint 耗时——如果 checkpoint 超过 5 分钟,考虑换 RocksDB 或优化序列化
  • 测试环境压一把——用生产流量的 1/10 跑 24 小时,看看状态增长曲线

嗯,State Backend 这块内容就这些。理解了存储原理,后面讲状态一致性的时候,你就能明白为什么不同的 Backend 对 Exactly-Once 的支持方式不一样了。