三周时间一个实时流计算作业的 checkpoint 耗时从 40 秒涨到 11 分钟故障恢复时间从 2 分钟涨到半小时。第一次看到这条曲线团队里几乎所有人的第一反应都是调参数把超时从 10 分钟调到 30 分钟把状态后端内存调大把机器从 16 核换成 32 核。折腾两天曲线只是稍微平了一点问题的根子一点没动。真正的原因不在参数上而在于他们把状态管理里的两类东西当成了同一回事流数据状态和流信息状态。前者的规模由业务数据决定后者的规模由作业自身的运行形态决定前者膨胀往往是业务逻辑写漏了边界后者膨胀往往是拓扑、位点、事务这些看不见的账本出了问题。两类东西混在一起看你永远不知道该往哪儿下手。这篇内容讲的就是这两类状态各自是什么、怎么长出来的、在什么环节互相咬合以及我在实际排查中总结出来的估算方法、参数取舍和踩坑记录。不管你是刚接手实时链路的开发还是已经在维护几十个作业的运维只要你的作业跑在 Flink 这类流计算引擎上、并且开了 checkpoint下面的内容应该都能对上号。1. 先把两个概念掰开流数据状态与流信息状态的分界线1.1 我用一句话给它们划界流数据状态是业务语义需要记住的东西流信息状态是引擎为了让自己能正确重启而需要记住的东西。这句话听起来简单但它决定了你面对一个膨胀的状态目录时第一眼该看哪里。举个具体例子。一个计算商品实时成交额的作业窗口里累加的金额、按商品 ID 去重的订单集合、和维表关联时缓存的商品信息这些都属于流数据状态——它们的存在是因为你的业务逻辑要求跨事件记住点什么。如果把这些状态全删掉作业还能跑只是算出来的结果不对。而另一类东西Kafka 分区当前消费到哪个位点、事件时间水位推进到了哪一刻、每个算子的状态在快照文件里的句柄、sink 端那个还没提交的事务 ID——这些如果丢了作业要么从错误的位点重跑要么恢复不起来要么下游看到重复数据。它们不参与任何业务计算但决定了这条流能不能被正确地重新讲一遍。需要说明的是这个划分不是框架文档里的正式术语是我自己在排查问题时用的分类方式。官方文档里统称 state不会帮你分这两层。但恰恰是这个区分能让你在半夜被告警叫醒时少走一半弯路。1.2 混着看会付出什么代价最常见的误判是看到 checkpoint 体积变大就去查业务代码里哪个 MapState 忘了清。这个方向对了一半但也可能完全错——有时候体积增长来自保留的历史快照太多每个快照引用了一批老的 SST 文件本地磁盘上这些文件因为还被快照引用着而无法回收有时候来自未对齐快照把 inflight 数据也存了进去体积天然比对齐快照大 20% 到 50%。反过来看到作业恢复慢很多人第一反应是状态太大要扩容。但恢复时间 下载快照时间 反序列化时间 状态重建时间 位点回追时间。如果瓶颈在最后一项——比如位点回追要重新消费 40 分钟的数据——你把状态缩小一半也没用该等的时间一秒不少。位点回追属于流信息状态的范畴得从水位和位点对齐策略上想办法。我在一个模拟项目里做过对照测试同样 60GB 的状态全量快照恢复用 9 分钟增量快照恢复用 3 分半但两者在恢复后的位点回追时间完全一致都是 4 分钟左右。也就是说那次故障里真正拖时间的部分有接近一半根本不在状态体积上。如果一开始就把两类状态分开算账这个结论五分钟就能得出来。1.3 一张表看清差异维度流数据状态流信息状态来源业务数据本身引擎运行过程规模由谁决定key 基数、窗口长度、TTL拓扑复杂度、快照保留数、事务数量典型载体ValueState、MapState、ListState、窗口缓冲位点、水位、快照元数据、算子 UID 映射、两阶段提交事务出问题时的表象快照变大、GC 频繁、恢复变慢恢复后重复消费、状态映射失败、事务悬挂、水位停滞能否直接删视业务语义通常不能部分可以过期的历史快照、已提交事务记录观测入口状态大小指标、RocksDB 文件统计checkpoint 元数据、位点指标、watermark 指标这张表我建议贴在排查清单最上面。它最大的作用不是告诉你答案而是让你在拿到一个异常指标时先花十秒钟判断该往哪边走。2. 流数据状态的四种典型形态与各自的膨胀规律2.1 累加器型看着最无害其实最怕 key 基数累加器型状态就是记住一个数窗口内的 sum、count、max或者每个 key 的累计值。用ValueStateLong或者窗口算子自带的聚合缓冲就能实现单个 key 的状态可能就几十个字节看起来完全不用担心。但它的膨胀规律是按 key 基数线性增长而且很多人会忽略窗口没关闭之前的 key 全部都在状态里。一个 1 小时的滚动窗口如果 QPS 是 5 万、key 是用户 ID那这一小时里出现过的所有用户都在状态里趴着直到窗口触发才释放。假设有 2000 万活跃用户每个 key 哪怕只占 100 字节key 本身 累加值 RocksDB 的内部开销那也是 2GB。如果窗口是 24 小时直接 48GB 起。更隐蔽的是滑动窗口。一个 size 1 小时、slide 5 分钟的滑动窗口框架内部会把数据复制到 12 个窗口实例里状态量差不多是滚动窗口的 12 倍。我见过有人把滚动窗口改成滑动窗口做实时看板checkpoint 从 800MB 直接跳到 9GB然后回头怀疑是框架有 bug。提示窗口类算子的状态量 ≈ 单 key 大小 × 窗口覆盖期内的去重 key 数 × 窗口重叠倍数。改窗口参数之前先把这三个数乘一遍。2.2 去重型被低估的基数黑洞去重是实时链路里最危险的状态形态没有之一。UV 计算、幂等消费、消息防重放本质上都是记住我见过什么。如果用MapStateString, Long存明细每个 key 除了业务字段RocksDB 还要存索引、序列化头、版本号实际占用往往是业务字段的 3 到 5 倍。日活千万的业务一天的明细去重表轻松上到几十 GB。而且这类状态有个特点它的增长速度是台阶式的早上八点开始涨晚上十二点回落凌晨那个 checkpoint 永远是一天里最小的白天那个永远最大。如果你只看平均值会严重低估峰值。我的做法是分三档处理基数在百万以内直接用 MapState 存明细简单可靠百万到千万换 RoaringBitmap 或者带分桶的布隆过滤器把精度换空间千万以上跑两层第一层用布隆过滤器做快速判断第二层只对可能见过的 key 回查外部存储。这个分层不是银弹布隆过滤器有假阳性得根据业务对准确率的要求来定误判率——通常 1% 的假阳性意味着多丢掉 1% 的重复数据如果业务能接受那就划算。2.3 关联型双流 JOIN 与维表缓存的两套账关联型状态分两种账要分开算。双流 JOIN 的状态是双侧对称的左流来一条要存起来等右流右流来一条要存起来等左流。所以状态量大致等于左流在 JOIN 窗口期内的数据量 右流在 JOIN 窗口期内的数据量。这里最容易犯的错是只给一侧设了 TTL。我见过一个订单和支付的 JOIN订单侧设了 24 小时过期支付侧没设跑了半年之后支付侧状态涨到 400 多 GB——因为有些订单永远不会有支付那些支付侧的中间记录就一直挂着。维表缓存是另一套账。为了避免每条数据都去打外部库通常会用MapState缓存维表记录加上 TTL。问题在于 TTL 的清理并不是实时的。框架的 TTL 是惰性清理加后台清理结合也就是说一条过期记录在被访问到或者后台任务扫到之前仍然占着空间。所以在 TTL 到期和空间真正释放之间有一个明显的滞后窗口。2.4 模式匹配型与定时器藏在框架里的状态CEP 的中间匹配结果和定时器队列是两种最容易被忽略的流数据状态。CEP 的问题在于状态量不可预测。一个5 分钟内下单超过 3 次的规则如果某个用户疯狂下单那这个用户的 partial match 链条会不断延长每个中间态都要存。规则越复杂、时间窗越长、乱序容忍度越大中间态的爆炸风险越高。我的经验是CEP 规则上线前一定要做一次单 key 压力测试用同一个 key 灌 10 万条符合前缀条件但不满足完整模式的数据看状态涨多少。这个测试十分钟能做完能避免上线后的一次事故。定时器队列则是另一回事。注册的 event-time timer 会以优先队列的形式存在状态里每条 timer 除了时间戳还有 key 的引用。如果一个 key 上每次事件都注册一个定时器、而定时器又不清理队列会无限增长。常见错误是在onTimer里只处理了业务逻辑忘了清理关联的状态或者用了registerEventTimeTimer却在数据迟到时重复注册同一个时间戳。3. 流信息状态决定作业能不能活下来的隐形账本3.1 位点、水位与它们的推进停滞意味着什么位点offset和水位watermark是流信息状态里最重要的两个量它们回答的是两个不同的问题位点回答数据读到哪了水位回答时间推进到哪了。位点由 source 负责维护在快照时被记录下来。这里有个容易被误解的点位点记录的是快照那一刻的消费位置不是最新消费位置。作业恢复时从快照位点重新消费意味着快照之后产出的数据会被重放一遍。这是 at-least-once 语义的来源也是为什么 sink 端必须做幂等或者事务。水位停滞是另一个高频问题。水位不推进所有基于事件时间的窗口都不会触发定时器也不会响整个作业看起来在跑但不出结果。原因通常有三类某个分区没有新数据空闲分区拖慢全局最小值数据本身的时间戳严重滞后上游时钟问题或者数据回补某个算子的反压导致水位传播被阻塞。空闲分区这个问题有现成解法开启withIdleness配置让长时间没有数据的分区不参与水位计算。但要注意这个超时时间设太短会在流量低谷时误判设太长又起不到作用。我一般按业务上正常的最长静默间隔 × 2来定。3.2 快照元数据与算子 UID 映射每次成功的 checkpoint 会在存储目录下留下一个元数据文件里面记录了这次快照包含哪些算子、每个算子的状态句柄指向哪些文件、并行度是多少、用什么序列化器。这个文件很小几 KB 到几百 KB但它决定了作业能不能恢复。真正关键的是算子与状态的映射关系。引擎靠算子的 UID 来匹配快照里的这份状态属于现在拓扑里的哪个算子。如果你没显式调用uid()框架会基于拓扑结构自动生成一个哈希值——这意味着你只要改一下算子顺序、加一个 filter、换个 sink哈希就变了恢复时匹配不上状态要么丢失要么报错。这个坑的杀伤力在于它有延迟性。开发阶段天天改拓扑反正每次都是全新启动没有任何感觉。等到上线三个月需要改一版逻辑重新发布时才发现状态恢复不了而这时候状态里已经积累了三个月的去重数据重建成本极高。提示所有 keyed 算子、所有 source 和 sink上线前必须显式设置 uid并且在团队内约定命名规范比如order-join-pay-v1。这个习惯养成之后后续的拓扑调整才有腾挪空间。3.3 两阶段提交里的悬挂事务如果 sink 端用了两阶段提交来保证端到端精确一次那就多了一份流信息状态未提交的事务记录。它的工作方式是每次快照时sink 把当前打开的事务信息一并存进快照快照全局完成之后框架回调通知 sink 去提交如果作业在快照完成和通知提交之间挂了恢复后会从快照里读到这些待提交事务重新提交一次。问题出在两个地方。一是提交通知可能丢失尤其在作业频繁重启的阶段事务就一直挂着。二是事务本身有超时如果挂的时间超过了事务超时时间再提交就会失败这时候要么数据丢失要么得人工介入。所以事务超时时间必须大于最长可能的恢复时间 快照间隔我一般设成快照间隔的 3 到 5 倍并且监控待提交事务的数量超过阈值就报警。3.4 这些信息该怎么观测流信息状态的观测和流数据状态完全不同。后者看状态大小前者看得是几个具体指标指标含义异常判断最近一次 checkpoint 的完成时间快照从触发到确认的耗时超过间隔的 60% 就要看checkpoint 失败次数连续失败会导致作业不重启连续 2 次就该介入各 source 分区的位点差最新位点与快照位点之差差值持续扩大说明在堆积水位与当前系统时间之差事件时间落后程度超过乱序容忍度要查上游待提交事务数量悬挂的两阶段提交记录单调增长就是泄漏状态句柄引用的文件数增量快照的文件引用数量爆炸说明合并失效这张表里的每一项我都至少遇到过一次真实异常。其中最容易被忽略的是最后一项——增量快照的文件引用数量。它不像其他指标那样直观但当它从几百涨到几万的时候恢复时打开文件的开销会变成主要瓶颈。4. Checkpoint 是两类状态的交汇点一次快照到底发生了什么4.1 Barrier 注入到确认完成的全链路理解 checkpoint 的完整链路是区分两类状态的关键因为这条链路上前半段处理的是流信息后半段产生的是流数据快照。触发由协调器发起它会向所有 source 注入一个特殊的标记barrier这个标记随数据流一起往下游走。source 收到标记时先记下自己当前的位点——这是流信息状态然后把标记继续往下发。中间算子收到标记后如果是精确一次语义需要做对齐等所有输入通道的标记都到齐。在等待期间先到标记的那些通道后续来的数据不能被处理只能先缓存起来。等全部到齐算子对本地状态做一次快照把标记继续下发。最后所有算子都确认完成协调器收到全部回执这次 checkpoint 才算成功然后通知 sink 去提交事务。这条链路上有三个时间点特别重要触发时刻、各算子完成本地快照的时刻、全局确认的时刻。三者之间的差异能告诉你瓶颈在哪。如果触发到本地快照很快但全局确认很慢说明有某个算子拖后腿如果本地快照本身就慢那是状态体积或者磁盘 I/O 的问题。4.2 对齐为什么会在反压下变成灾难对齐这个机制在正常情况下开销很小但在反压状态下会变成恶性循环。想象一个算子有两个输入通道通道 A 的标记先到了通道 B 因为上游处理慢迟迟不到。这期间通道 A 的数据全部堆在内存缓冲区里等着。缓冲区满了就产生反压反压传回通道 A 的上游上游更慢整个链路雪崩。而通道 B 的标记还是没来因为它上游本来就慢。这个循环一旦形成checkpoint 超时几乎是必然的。而 checkpoint 超时会导致作业重启重启后又要从上一个成功的快照开始回追回追期间数据积压更严重反压更厉害——正反馈。解法是未对齐快照不去等标记对齐而是把当前缓冲区里的在途数据也一并持久化到快照里。这样标记可以立刻穿过去不会卡住。代价是快照体积变大因为多存了在途数据而且恢复时需要先把这些在途数据重新灌回去恢复路径更复杂。什么情况下该开我的判断标准是如果 checkpoint 超时和反压指标同时出现且反压是间歇性的不是持续满载开未对齐收益明显如果作业长期处于高负载、在途数据量巨大未对齐会让快照体积膨胀到不可接受那就该先解决反压本身比如加并行度、优化算子逻辑、扩上游分区。4.3 增量快照与 RocksDB 的 SST 复用用 RocksDB 作为状态后端时增量快照是必开项因为它把快照成本从每次全量拷贝降到只传变化的文件。原理不复杂RocksDB 的数据按层组织成一批不可变的 SST 文件一次快照就是记录当前这批文件的清单。下次快照时大部分文件没变只有新 compaction 产生的那部分不同只需要上传差异部分旧文件继续被引用。这里有两个容易踩的点。第一本地磁盘上的文件不能随便删。因为历史快照还在引用它们只要有一个快照没被清理对应的文件就必须留着。所以你会看到本地磁盘占用远大于当前状态大小这是正常的但如果保留的快照数设得太多比如 10 个磁盘可能被撑爆。我一般保留 3 到 5 个够应付连续失败重试就行。第二compaction 的节奏会直接影响快照体积。如果 compaction 太频繁每次快照都有大量文件变化增量的优势就没了如果 compaction 太懒文件数堆积恢复时要打开的文件句柄数量暴增。这个平衡靠 RocksDB 的后台任务线程数和各层文件数阈值来调不是一个参数能解决的。4.4 恢复阶段的时间到底花在哪恢复过程可以拆成四段下载快照、反序列化、状态重建、位点回追。这四段分别对应不同的优化手段混在一起谈恢复慢是没有意义的。阶段时间占比我的经验值优化方向下载快照20% 到 40%对象存储带宽、增量快照、压缩算法反序列化10% 到 20%序列化器选型、避免嵌套结构状态重建10% 到 20%减少文件数、合理设置并行度位点回追20% 到 50%缩短快照间隔、提高上游吞吐、控制回追范围最后一项经常被完全忽略。位点回追是重新消费快照之后的数据这段时间作业在处理但没有产出——因为窗口还没触发、因为状态还在追平。如果快照间隔是 10 分钟平均回追时间就是 5 分钟如果你的业务要求 1 分钟内恢复那快照间隔必须压到 2 分钟以内同时接受更频繁的快照开销。这就是一个典型的取舍快照频率换恢复速度。没有标准答案只能按业务的 RTO 要求倒推。5. 状态规模估算与状态后端选型5.1 一个可落地的容量估算公式比起上线后用监控看我更倾向在上线前先算一遍。公式如下总状态量 ≈ 单 key 平均状态大小 × 活跃 key 数 × 版本放大系数 × 冗余系数四个因子的含义单 key 平均状态大小不要用业务字段的长度估算要加上序列化开销。用 RocksDB 时key 要存一份、value 要存一份再加上内部索引和版本头实测经验是业务字段的 1.5 到 2 倍。有嵌套结构或者集合类型的倍数更高。活跃 key 数指的是在状态生命周期内至少出现一次的 key 数量不是总用户数。一个 TTL 7 天的去重表活跃 key 数约等于 7 天内的去重用户数。版本放大系数只有用 RocksDB 的时候才需要考虑。RocksDB 的写入是追加式的同一个 key 的多次更新会以多个版本存在直到 compaction 合并。这个系数在写多读少的场景下可能达到 2 到 3。冗余系数考虑峰值和平均值的关系。日活类业务峰值通常是均值的 2 到 3 倍大促类业务可能到 10 倍。按峰值算别按均值算。我在一个模拟项目上的实测预估 120GB实际稳定在 145GB 左右偏差 20% 上下。这个精度对容量规划已经够用了至少不会出现准备了 100GB 磁盘结果要 1TB的情况。5.2 HashMapStateBackend 与 RocksDB 的真实分界选型这件事很多资料给出的判断标准是状态大就用 RocksDB状态小就用内存。这个标准太粗实际决定因素有三个。第一是状态能不能全放进堆内存且留有余量。注意是余量不是能放下。堆内状态会参与 GC状态越大GC 停顿越长而 GC 停顿会直接表现为处理延迟抖动。我的经验阈值是状态量不超过堆内存的 30%可以考虑内存后端超过 40%别犹豫上 RocksDB。第二是有没有范围扫描或者迭代需求。RocksDB 支持按 key 前缀遍历内存后端也支持但性能特征不同。如果你的逻辑需要定期扫描全部状态做对账RocksDB 的顺序 I/O 优势会很明显。第三是恢复时间要求。内存后端的快照是堆内对象的序列化恢复时要全部反序列化进堆大状态下这个过程会很慢而且容易 OOM。RocksDB 的快照是文件恢复时是文件加载加索引构建相对平稳。还有个实际因素运维复杂度。RocksDB 引入了一整套需要调的参数block cache、write buffer、compaction 策略调不好反而比内存后端慢。如果团队里没人愿意啃这块用小状态 内存后端 定期清理可能比硬上 RocksDB 更稳。5.3 RocksDB 内存参数三个数字决定吞吐稳定性如果确定用 RocksDB有三个参数必须调默认值在流计算场景下通常不合适。block cache读缓存。默认值偏小读多写少的场景下会导致频繁读盘。建议设为可用内存的 30% 到 40%开启托管内存后由框架统一分配。write buffer写缓冲。这个值决定了多少次写入触发一次落盘和 compaction。默认通常是 64MB如果单条数据大、写入频繁会频繁触发小文件 compaction写放大严重。我会设到 128MB 到 256MB同时把 write buffer 数量设成 3 到 4 个让写入和 compaction 能并行。后台 compaction 线程数。这个值太小compaction 跟不上写入速度文件数会堆积太大会和数据处理抢 CPU。一般设成 CPU 核数的 1/4 到 1/2。这三个参数的关系不是独立的write buffer 越大单次 compaction 的数据量越大需要的后台线程也越多。调的时候要一起看不能只改一个。6. 故障现场四个症状的排查链路6.1 症状一checkpoint 越来越大恢复越来越慢这是最常见的症状也是最容易误判的。完整的排查链路应该是这样第一步先看是持续增长还是台阶式增长。持续线性增长八成是某个状态没有清理机制台阶式增长每天固定时间涨、固定时间回落说明是业务周期导致的属于正常现象重点看峰值是否超出容量。第二步看增量快照的文件引用数。如果文件数在涨但总大小稳定是 compaction 没跟上如果总大小也在涨是真的有数据在积累。第三步逐个算子看状态大小分布。框架的管理界面会给出每个算子的状态量找到占比最大的那个基本就锁定目标了。这里有个技巧不要只看绝对值看增长速率。一个 10GB 但三个月没涨的状态和一个 2GB 但每周翻倍的状态显然后者更危险。第四步对着那个算子查三件事状态有没有设 TTL、TTL 的清理策略有没有配、状态里的 key 是不是无界的比如用设备 ID 而不是用户 ID设备 ID 会无限增长。我遇到过一个典型案例一个实时风控作业状态每周涨 8%。查下来是一个存用户最近操作序列的 ListState逻辑是每次操作 append 一条超过 100 条时截断。看起来没问题但截断逻辑写在了处理成功的分支里异常分支直接返回了导致失败重试的数据全部堆积。修复方式是把截断提到公共路径或者干脆换成有 TTL 的 MapState。6.2 症状二改并行度后状态对不上这个症状的表现是恢复报错或者恢复后数据明显不对。根因几乎都出在 UID 或者最大并行度上。排查顺序先确认所有算子有没有显式 uid。没有的话基本可以确诊。如果有再确认最大并行度有没有被改过——这个值决定了 key 分组的槽位数一旦作业上线就不该再动。改并行度本身是安全的状态会按 key 组重新分配但改最大并行度会让整个映射关系失效。还有一种隐蔽情况两个算子的 uid 写重复了。框架不会报错但状态会串到一起表现为某个算子的状态莫名其妙多了一堆不认识的 key。这种问题排查起来特别费劲因为表象完全不像配置问题。提示把 uid 纳入代码评审的检查项。新增算子必须带 uid命名规范统一发布前用脚本扫一遍有没有重复。6.3 症状三反压与 checkpoint 超时同时出现这两个指标同时出现说明已经进入了我前面说的正反馈循环。这时候不要急着调 checkpoint 超时那只是把问题往后推。第一步看反压的源头在哪个算子。从 source 往下游逐个看反压指标找到第一个出现反压的算子那才是真正的瓶颈后面都是被传染的。第二步判断瓶颈的性质。如果是 CPU 打满看是不是有热点 key 或者低效的序列化如果是 I/O 等待看是状态读写还是外部调用如果是 GC 停顿看堆内存配置和对象创建速率。第三步才是决定要不要开未对齐快照。如果瓶颈能在短时间内解决比如加并行度、优化一个明显的低效算子优先解决瓶颈如果一时半会儿解决不了先开未对齐把 checkpoint 稳住避免作业反复重启放大问题。6.4 症状四作业重启后重复消费或数据丢失重复消费的根因通常是位点回退。确认两件事sink 端是不是幂等的或者有没有用事务快照间隔是不是太长导致回追范围过大。数据丢失的原因更隐蔽常见的三种一是 sink 用了至少一次以外的配置比如异步提交没有等确认二是两阶段提交的事务超时导致提交失败被丢弃三是 checkpoint 配置成了失败不重启某次失败后从更早的快照恢复而中间那段时间的数据被上游的保留策略清掉了——这种情况在 Kafka 上很常见如果 topic 的保留时间短于故障恢复时间回追时就找不到数据了。7. 运维侧的几个参数与习惯7.1 我固定会改的那几个配置不管什么作业上线前这几项我都会确认一遍# 快照间隔按 RTO 倒推通常 1 到 3 分钟 execution.checkpointing.interval: 120s # 超时间隔的 5 到 10 倍给慢节点留余量 execution.checkpointing.timeout: 15min # 连续失败容忍太低会频繁重启太高会掩盖问题 execution.checkpointing.tolerable-failed-checkpoints: 3 # 保留快照数够重试就行多了占磁盘 execution.checkpointing.num-retained: 3 # 状态后端 state.backend.type: rocksdb state.backend.incremental: true # 托管内存让框架统一分配 state.backend.rocksdb.memory.managed: truenum-retained这个值我踩过坑。早期图省事设成 10结果本地磁盘被历史快照引用的文件占满作业直接挂了。后来统一改成 3配合外部存储的独立保留策略磁盘再没出过问题。还有一个不在配置里但同样重要的习惯给每个作业标注状态量级和维护责任人。几十个作业跑在一个集群上出问题时第一个要回答的问题是这个作业的状态有多大、谁在维护如果没标注光确认这两件事就要花半小时。7.2 TTL 不是万能药很多人一听状态膨胀就加 TTL但我见过太多加了 TTL 之后状态没降下来的案例。原因有四个一是 TTL 只对 keyed state 生效算子内部的普通成员变量不受管序列化到快照里照样占空间。二是 TTL 的时间语义容易搞错处理时间和事件时间是两回事用事件时间做 TTL 时如果水位不推进TTL 永远不会触发。三是 TTL 的清理有滞后过期数据要等到被访问或者后台扫描才真正删除这期间还占着空间。四是 TTL 会影响增量快照的效率因为 TTL 触发的删除会在 compaction 时产生大量文件变更反而让增量优势下降。我的建议是TTL 作为兜底手段而不是主要手段。真正的解法是从业务逻辑上让状态有界——去重表加上时间分片、维表缓存设上限、JOIN 状态明确生命周期。TTL 只用来兜住那些理论上应该清理但实际漏了的情况。7.3 上线前必做的三件事第一件做一次单 key 压力测试。用同一个 key 灌 10 万条数据看状态涨多少、处理延迟怎么变化。这个测试能提前发现绝大多数状态设计缺陷成本只有十分钟。第二件模拟一次从快照恢复。不是简单的重启而是手动触发一次快照然后从那个快照启动一个新作业观察恢复时间和恢复后的数据正确性。很多状态映射问题只有在这个流程里才会暴露。第三件算一遍状态量的上界。按峰值流量、最大 key 基数、最长 TTL 算一次和磁盘容量对比。如果算出来的数字超过容量的 60%说明预算不够得在上线前就调整方案而不是等出事。说到底流计算里的状态管理难点从来不是怎么用 API而是搞清楚哪些东西在被记住、为什么会增长、增长到什么程度会出问题。把流数据状态和流信息状态分开看是我这几年最有用的一条经验——它不能直接给你答案但能让你在排查时少绕很多弯路。我自己的习惯是每次接手一个新作业先花二十分钟把这两类状态各列一张清单写清楚每个状态的载体、规模、生命周期和维护方式后面所有的排查和优化都从这两张纸开始。