做实时数仓这两年,我最怕的不是数据延迟,而是状态只增不减。第一次遇到 Flink 作业内存溢出崩溃,排查到最后发现是按键分区状态里的 MapState 存了上亿条用户行为记录,磁盘直接吃掉 200 多 GB,作业恢复一次要半小时。后来我把状态生存时间(TTL)系统性地用起来,才彻底解决了这个“只进不出”的问题。这篇内容不是官方文档的翻译,而是我在真实生产环境里把 State TTL 从配置到监控踩通之后整理的完整笔记。它适合两类人:一类是被无界状态内存上涨折磨的 Flink 开发者,另一类是刚接触有状态计算、想知道 TTL 到底怎么生效的入门者。
1. 状态只增不减:一次真实的状态爆炸事故复盘
1.1 现象:状态从几个 GB 涨到 200 GB 只用了两周
那是某个实时用户标签作业,每天从消息队列里读取行为事件,按 userId 做 KeyBy 后,用 ListState 维护用户最近 30 天的访问记录。一开始状态只有几个 GB,两周后直接冲到 200 多 GB。作业开始频繁 GC,接着 RocksDB 的磁盘占用把数据盘打满,checkpoint 持续超时,最终状态恢复也要跑 30 分钟以上。当时的第一反应是加内存、加并行度,但效果都很差。内存加了 32 GB,才多撑 3 天;并行度从 20 调到 40,状态总量却没降。加内存和加并行度只能延缓状态爆炸,不能解决状态爆炸。那次事故后,我把整个 Flink 作业的状态管理从头捋了一遍,才真正意识到:在 Flink 里,按键分区状态的生命周期默认是“跟着整个作业走”,永远不主动删除。
1.2 根因:Keyed State 的“只增不删”特性
Flink 的按键分区状态(Keyed State)在逻辑上就是 key 到 value 的映射。无论是 Heap 还是 RocksDB 状态后端,存储层只会根据操作触发写入和更新,不会像数据库那样因为某个字段不再满足条件就自动回收。用户 30 天没有新行为,他对应的状态依然躺在那里。除非作业重启后 key 被重新分配,或者用户显式调用 clear(),否则旧数据几乎不会消失。实时任务最怕这种“只增不删”:数据是流的,状态是存量,业务要求只保留最近 N 天,但代码里没有定义过期规则,最终状态就会逼近无穷大。TTL 解决的核心问题,就是把“状态保留多久”这件事从业务代码中显式抽取出来,让状态后端按时淘汰不需要的数据。
1.3 TTL 不是万能的:它适合解决什么,不适合解决什么
状态生存时间适合用于会话统计、最近活跃用户、缓存型状态、防重校验这类对时间敏感的业务。比如“30 天未登录用户”“最近 1 小时点击流”“订单超时未支付”这些场景,数据一旦超过业务容忍的时间窗口,就完全失去保留价值。TTL 不适合用于全量累计型状态,比如总交易金额、历史最高分、累计投放预算。这些状态需要永久保留,设置 TTL 会导致数据过早丢失,而且 Flink 事后无法恢复。另一个容易被忽略的点是:TTL 不等同于“清理垃圾”。如果状态 key 数量巨大且写入频繁,TTL 的清理本身也会消耗 CPU 和磁盘 IO。所以在动手配置前,先想清楚业务上哪种数据可以丢弃,比调参数更重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. TTL 底层机制拆解:过期时间戳、可见性与三层清理策略
2.1 每个状态值背后都藏着“隐形过期时间”
为了搞清楚 TTL 为什么能清理状态,我翻过 Flink 源码,简单说它的实现思路:启用 TTL 后,状态后端的 value 不再只存用户原始数据,而是存一个包装结构:{expirationTimestamp, userValue}。每次写入状态时,Flink 会取当前时间加上 TTL 时长,生成该条状态的过期时间,随数据一起序列化。读取状态时,先看当前时间是否已经超过过期时间。如果超过,就认为过期。这和消息队列里的消息过期机制不同:消息中间件是托管消息,有独立的过期扫描线程;Flink 状态是“懒检查”。它不会主动为每条状态启动一个计时器,那样开销太大,而是把过期时间作为元数据写入,在下一次访问或后台清理时判断。用一个生活类比:牛奶包装上的保质期不是到点自动变质的机关,而是你喝之前看日期来决定能不能喝。TTL 也是这个逻辑,它决定的是“过期时间怎么记录、什么时候被判断”。
2.2 更新时间策略:固定过期还是滑动过期
StateTtlConfig.UpdateType 提供两种刷新策略。
OnCreateAndWrite:状态被创建时设置过期时间,之后每次调用update()写入新值,会把过期时间刷新为当前时间 + TTL。读取状态不会影响过期时间。OnReadAndWrite:在OnCreateAndWrite基础上,每次读取到状态也会把过期时间续期。相当于滑动过期。
选择依据很简单:如果业务希望“从最后一次活跃开始算有效期”,比如用户在线状态,用 OnReadAndWrite;如果业务希望“从创建/赋值时间开始算固定有效期”,比如优惠券领用、活动发放权益,用 OnCreateAndWrite。需要提醒的是,OnReadAndWrite 并不是免费的。每次读取状态都要重写一遍带时间戳的 value,这会增加读写放大。对于高频访问的热点 key,这个开销不能忽略。我见过有同学设置 OnReadAndWrite 后,RocksDB 的写入量直接翻倍,就是因为每次 value() 都触发了写操作。
2.3 读取可见性:过期数据不会立刻从世界上消失
StateVisibility 控制读取过期状态的可见行为,有两个选项:
NeverReturnExpired:默认推荐。读取时如果发现已过期,先删除,再返回 null、空列表或默认值。能保证业务层永远读不到过期数据。ReturnExpiredIfNotCleanedUp:如果过期状态还没有被清理线程删除,读取时会直接返回旧值。
这个设计非常容易踩坑。不少人以为 NeverReturnExpired 就能“物理删除”过期数据,其实它只是在读取路径上做了一次过期判断,真正的物理清理依然交给清理策略。而 ReturnExpiredIfNotCleanedUp 是为了降低读取路径的删除开销,但代价是业务可能读到过期值。如果业务对过期值容忍度极低,建议始终使用 NeverReturnExpired。如果你选择 ReturnExpiredIfNotCleanedUp,后续排查数据“幽灵复现”时,第一个要看的配置就是它。
2.4 三套清理机制:惰性、快照与后台清理
- 惰性删除:在每次状态读/写时,如果发现该条状态已过期,就删除。这是最基础的清理,开销最小,但不能保证及时清理。
- Full snapshot cleanup:在做 Checkpoint 或 Savepoint 时,遍历全量状态,删除已过期条目。优点是清理彻底;缺点是快照时间变长,状态越大越明显,不适合大状态。
- 增量清理:状态后端在处理记录时,按一定频率挑出部分 key 检查并删除过期数据。它避免了一次性全量遍历的卡顿,但会有清理滞后。
- RocksDB compaction filter:在 RocksDB 做 LSM Compaction 时,由底层过滤器把过期数据丢弃。对 RocksDB 后端很友好,几乎不影响写入路径,但只对通过 Compaction 的 key 生效。
Flink 新版本把这些策略封装成不同的 API 方法,比如 cleanupIncrementally(...)、cleanupInBackground()。在生产环境里,单纯依赖某一种策略都不够,我通常在 RocksDB 后端同时开启增量清理和后台 compaction filter,让它们互补。
3. 动手配置 TTL:ValueState、MapState 到 ListState 的完整示例
3.1 最简配置:给 ValueState 加 24 小时 TTL
原理讲一堆,不如直接看代码。先看最常用也是最简单的场景:给一个 ValueState<Long> 设置 24 小时过期。
java复制import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.hours(24))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.cleanupInBackground()
.build();
ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<>("lastLogin", Long.class);
descriptor.enableTimeToLive(ttlConfig);
关键不在于 newBuilder 这几行,而在于 enableTimeToLive(ttlConfig) 必须在 ValueStateDescriptor 上调用。很多人配置写对了,但状态描述符在 open() 方法里重新创建时忘了绑定 TTL,结果始终不生效。正确做法是把 descriptor 设为类成员变量,在 open() 中直接使用;或者在构造 KeyedProcessFunction 时传入。如果你用 DataStream API 的 keyBy(...).process(...),状态描述符通常放在函数内部创建,并且是一次性初始化,注意不要每次都 new 一个新的。
3.2 MapState 的逐条目过期:适合滚动窗口类业务
MapState 是个特殊存在。它虽然整体是一个 key 下的状态,但 TTL 可以做到 per-entry,也就是 map 里的每一个条目都有独立的过期时间。这是它和 ValueState、ListState 最大的不同。如果业务形态是 “key -> (fieldKey -> fieldValue)”,比如某个 userId 下挂了多个 skuId 的最近价格,希望每个 skuId 单独算 TTL,用 MapState 非常合适。配置方式和 ValueState 一样,只是描述符类型变了:
java复制import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
StateTtlConfig mapTtlConfig = StateTtlConfig
.newBuilder(Time.hours(6))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
MapStateDescriptor<String, Double> skuPriceDesc =
new MapStateDescriptor<>("skuPrice", BasicTypeInfo.STRING_TYPE_INFO, BasicTypeInfo.DOUBLE_TYPE_INFO);
skuPriceDesc.enableTimeToLive(mapTtlConfig);
这里需要留意,MapState 的 updateType 不是针对整张 map 做的统一刷新。按官方语义,MapState 的 TTL 是逐条目的,OnCreateAndWrite 会在某个 entry 被写入时单独刷新该 entry 对应的过期时间;OnReadAndWrite 会在这个 entry 被读取时单独续期,不会影响其他 entry。所以如果你要做一个“每个子 key 各自滑动过期”的容器状态,MapState 是更贴近模型的选择。
3.3 ListState 与 ReducingState 的 TTL 使用差异
ListState 通常被误以为是“列表里的每个元素单独过期”,实际上不是。ListState 在一个 key 下是一个整体,TTL 过期粒度是整个列表,不是单个元素。也就是说,一旦这个 key 对应的 ListState 到期了,整个 list 都会被认为过期。如果你需要“列表中每条记录有独立过期时间”,ListState 做不到,建议改用 MapState<String, Long>(元素 ID -> 过期相关数据),或者自己用一个有序结构存储时间戳,用定时器清理。ReducingState 和 AggregatingState 也是同样的整体过期逻辑,它们是不断聚合的增量值,过期后整个聚合值会被清除。这个差异在业务建模时非常重要:不要假设 TTL 会精确到“集合里的每一笔数据”。
3.4 完整可跑示例:未支付订单 30 分钟自动失效
我把一个真实生产里用过的简化版贴出来。场景是:用户下单后 30 分钟内未支付,状态自动失效,如果之后才支付,不再做超时判断。这里用 ValueState<OrderInfo> 保存订单信息,TTL 设为 30 分钟。
java复制public class OrderTtlJob {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.minutes(30))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.cleanupInBackground()
.build();
ValueStateDescriptor<OrderInfo> orderDesc =
new ValueStateDescriptor<>("order-info", OrderInfo.class);
orderDesc.enableTimeToLive(ttlConfig);
env.addSource(new OrderSource())
.keyBy(event -> event.getUserId())
.process(new KeyedProcessFunction<Long, OrderEvent, Alert>() {
private transient ValueState<OrderInfo> orderState
