1. 为什么放弃 Spark Streaming:微批模型在实时 ETL 里的三块短板
做实时 ETL 这几年,我踩过最深的坑是把 Spark Streaming 当成万能实时引擎。不是说它不行,而是当你开始做秒级预警、CDC 同步、精确一次写入这些事时,微批模型带来的别扭会一层层显现出来。今年年初我们终于把核心实时链路从 Spark Streaming 全部迁到了 Flink,后续又花了两个季度做全链路优化。这篇文章就是那次升级全过程的复盘,适合正在评估流引擎选型、或者已经决定迁 Flink 但不知道怎么落地的团队参考。
1.1 秒级延迟只是表象,微批调度才是硬伤
很多人把 Spark Streaming 和 Flink 的差距简单理解成“延迟差了几秒”,但实际影响远不止这么简单。
Spark Streaming 的工作方式是把连续的数据流切成一个个小 batch,每个 batch 由 Spark 的 DAG 调度器执行。这里有个天然矛盾:batch interval 设得越小,调度和 task 启动的固定开销占比就越高;设得越大,数据积压就越明显。生产环境里我用过 2 秒、5 秒间隔,再往下到 500ms 时集群资源消耗陡增,但延迟并没有等比例下降。
端到端延迟不只是“数据到齐”的时间。Spark Streaming 要等 batch 边界,要等 executor 启动任务、拉取 shuffle 数据,处理完还要写结果并提交 offset。在同一个 3 节点测试集群上,我拿同一份订单流数据做过对比:Spark Structured Streaming 端到端 P99 大约 3.2 秒,Flink 大概 400ms。这个差异对于分钟级报表无所谓,但你要做实时大屏、风控阈值告警、活动秒杀监控,每一秒的延迟都直接影响业务判断。
Flink 是真正的逐条事件流处理,每个算子内部通过有界队列异步流转,没有“等下一批”的语义。配合事件时间和 watermark 机制,它能在窗口层面严格控制乱序,而不是简单按到达时间切 batch。这一点在数据有乱序、延迟到达的电商场景里尤其重要。
1.2 exactly-once 的实现成本,Flink 比 Spark 低一个量级
实时 ETL 最怕重复数据。上游 Kafka 重放、网络抖动、task 失败重试,任何一个环节出了问题,下游报表数字就会偏。Spark Streaming 想做到端到端 exactly-once,需要三样东西配合:WAL 记录输入日志、手动管理 offset、输出端还要支持幂等或事务。这等于把分布式事务的复杂度甩给了开发者。
我印象最深的是用 Spark Structured Streaming 写 MySQL 的场景。为了保证不重复,我不得不在写入层用唯一键做 upsert,再配合 checkpoint 管理 offset。这套方案能跑,但每次调整逻辑都要小心,否则 offset 提交和结果写入一旦不一致,对账对到怀疑人生。
Flink 把 exactly-once 做成了框架能力。它的 checkpoint 机制定期对分布式状态做快照,失败恢复时从最近一次完成的 checkpoint 重新做,下游通过两阶段提交(two-phase commit)统一提交事务。你只需要在 connector 层面选择支持 exactly-once 的 source/sink,比如 Kafka、JDBC 带主键的 upsert,剩下的脏活框架包了。
这条差异直接决定了维护成本。Spark Streaming 的 EOS 实现像手动挡,你要随时关注离合和转速;Flink 更像自动挡,设置好 checkpoint 参数后基本不用操心,除非你自己写出有副作用的外部调用。
1.3 连接器生态和 CDC 支持,Spark Streaming 的明显短板
实时 ETL 绕不开 CDC(Change Data Capture)。业务库的订单表、库存表一变,实时链路就得跟着变。Spark Streaming 想接 CDC,通常是自己起一个 Debezium Embedded 引擎消费 binlog,再把变更事件转成 DataFrame。听起来可行,但你要处理全量快照与增量 binlog 的切换、offset 持久化、DDL 变更的同步、分库分表数据汇聚。这一套下来,基本是重新造了个轮子。
Flink 这边直接用 Flink CDC connector,一个依赖、几行 DDL 就能把 MySQL 整库实时同步到 Kafka 或数据湖。Flink CDC 2.x 之后还支持增量快照,会把大表按主键范围切分成多个 chunk 并行读取,不会因为全量扫描把线上库压垮。
除此之外,Flink 的 connector 生态覆盖面广很多。我们链路里用到的 Kafka、MySQL、ClickHouse、Hudi、Doris,都有官方维护的 connector,而且 Flink SQL 里用 CREATE TABLE 就能声明一张源表或结果表,比在 Spark 里调各种 DataFrameWriter 接口要直观得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 升级前的准备:版本选型、部署方式与迁移边界评估
2.1 Flink 版本怎么选:我选了 1.16.2,而不是最新版
技术选型最忌讳“拿新开刀”。我们评估了几条线的稳定性后,锁定了 Flink 1.16.2。
选择依据是这样排的:Flink 1.13 开始做了一个很重要的整合,DataStream API 和 Table API 的流批统一走向成熟;1.14 解决了早期版本里很多状态后端和 SQL 的坑;而 Flink CDC 2.3 之后才支持动态加表、分库分表合并这些我们在意的高级特性,它和 Flink 1.16 的配合验证案例明显多于其他版本。
还有一个硬约束是 Hadoop/YARN 版本兼容。我们的集群在 CDH 6.3.x 体系下,社区版 Flink 对 Hadoop 3.x 的兼容性比 Hadoop 2.x 时代干净很多,但不同发行版对 flink-shaded-hadoop 的处理并不一致。我建议在正式选型前,先在测试环境用你们的真实 YARN 配置跑一个 validate 作业,确认资源申请、日志聚合都正常,再定版本。
版本定了之后,把依赖锁定到 pom 里,团队内部统一用同一套镜像或同一个 lib 目录,不允许每个人自带不同版本的 flink-client 打进去。这个细节我们后来吃过亏,在排查 Datasophon 上传 Job 失败时发现,相当一部分问题就是依赖版本打架导致的。
2.2 部署模式:优先 Flink on YARN Application 模式
我们没有单独维护一套 Flink Standalone 集群,理由很直接:资源没法弹性调度。Standalone 模式下 TaskManager 是静态分配的,业务高峰期扩不了、低谷时缩不回来,和 Spark 作业抢 YARN 资源时只能干瞪眼。
Flink on YARN 我们主要用 Application 模式。它的特点是把用户作业的 main 方法放到 Application Master 里执行,作业启动时一次申请所需容器,提交之后内部自动协调。相比 Per-Job 模式每提交一个作业都要先启动一个单独的 AM,Application 模式整体启动更快,资源利用率也更高。
一个典型的提交命令长这样:
bash复制flink run-application -t yarn-application \
-Djobmanager.memory.process.size=2048m \
-Dtaskmanager.memory.process.size=4096m \
-Dtaskmanager.numberOfTaskSlots=4 \
-Dparallelism.default=8 \
-Dstate.backend=rocksdb \
-Dstate.checkpoints.dir=hdfs:///flink-checkpoints \
-c com.example.OrderEtlJob \
hdfs:///flink-jars/order-etl-job.jar
有个容易被忽视的坑:Application 模式下,用户 jar 必须能被 YARN 的 ApplicationMaster 节点访问到,所以我们会先把 jar 上传到 HDFS,再通过 hdfs:// 路径提交。如果直接传本地路径,会因为 AM 所在节点和你本地机器不是同一台而报 FileNotFoundException。另外,-Dtaskmanager.numberOfTaskSlots 建议设成与宿主机 CPU 核数匹配,而不是贪多,否则线程上下文切换反而拉低吞吐。
2.3 迁移边界:不是所有作业都值得迁
从 Spark Streaming 到 Flink 不是“全量迁移”就完事了,项目初期我把作业分了三个档位。
第一档是明确要迁的:实时大屏指标、实时数仓 ODS 层写入、核心订单风控、MySQL 到 Kafka 的 CDC 同步。这些场景要么对延迟敏感,要么对状态管理要求高,Flink 的收益是碾压级的。
第二档是值得评估的:分钟级报表聚合、日志清洗转发。如果这些作业在 Spark Streaming 上已经稳定运行,而且对延迟不敏感,迁移会引入新的运维风险。我们的处理方式是保留一部分,等 Flink 链路跑稳后按优先级再迁。
第三档是完全不该迁的:纯离线批处理。用 Spark 的 DataFrame API 做 T+1 汇总,Spark 的成熟度、SQL 优化器、生态文档都明显优于把 Flink 当批处理引擎用的做法。虽然 Flink 也支持批模式,但没必要为了“统一引擎”给自己找额外负担。
迁移顺序上,我们从最核心的订单实时大屏开始灰度,小流量切 10%、30%、50%、100%,每档观察至少 24 小时。灰度期间用双跑对比校验数据一致性,等两个链路的数据 no diff 稳定一周,才关掉 Spark Streaming 的旧任务。
3. 核心 ETL 算子的改造落地:从 DStream 到 DataStream 的思维切换
3.1 窗口与时间语义:最容易别“惯性”带偏的地方
Spark Streaming 里窗口计算最常见的写法是 reduceByKeyAndWindow 或者 window,默认基于处理时间(Processing Time)。数据什么时候到,窗口就什么时候触发,完全不关心数据本身的业务发生时间。这在很多业务场景会产生误差:比如用户 23:59:59 下单,服务端处理到 00:00:01,行为被你归到了第二天。
Flink 里可以把事件时间(Event Time)作为主导时间语义。数据里携带业务时间戳,通过 Watermark 机制告诉框架“这个时间之前的数据已经到的差不多了,可以触发窗口计算了”。
我们订单实时统计的改造代码大致是这样:
java复制DataStream<OrderEvent> streamWithWatermark = source
.assignTimestampsAndWatermarks(
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner((event, ts) -> event.getEventTimeMillis()));
streamWithWatermark
.filter(event -> event.getStatus() == OrderStatus.PAID)
.keyBy(OrderEvent::getShopId)
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.aggregate(new OrderAmountAggregate(), new OrderAmountWindowOutput())
forBoundedOutOfOrderness(Duration.ofSeconds(10)) 意思是容忍最多 10 秒的乱序数据。这 10 秒不是随便拍的,我结合业务实际乱序情况看了几周数据,算清 P95 和 P99 乱序延迟之后才定了 10 秒。设太大会让窗口结果晚出,设太小会让迟到的数据被丢掉。
还要单独处理迟到数据。我们的做法是开启 allowedLateness(Time.minutes(1)),把超过 watermark 但还在允许范围内的数据放到延迟数据流里,落一个 Kafka 侧主题方便后续订正,而不是直接丢弃。这一步对实时报表“数据回跳”问题非常有效。
3.2 状态管理与 Checkpoint 参数:从不关心到必须关心
Spark Streaming 里做有状态计算(mapWithState、updateStateByKey)虽然能用,但状态管理对开发者来说是个黑盒,你很难直观控制状态后端的存储、清理、恢复策略。Flink 把状态当成一等公民,你可以选择状态后端、配置 TTL、手动快照、查看状态大小。
我们所有线上作业统一使用 RocksDB 状态后端,配置如下:
yaml复制state.backend: rocksdb
state.checkpoints.dir: hdfs:///flink-checkpoints
state.backend.incremental: true
execution.checkpointing.interval: 60s
execution.checkpointing.timeout: 10min
execution.checkpointing.min-pause: 30s
execution.checkpointing.max-concurrent-checkpoints: 1
execution.checkpointing.tolerable-failed-checkpoints: 3
这里解释一下几个关键参数:
execution.checkpointing.interval设 60 秒,是因为我们下游对恢复延迟的容忍度在 1 分钟左右。间隔太短会导致频繁持久化状态,RocksDB 的压力大;太长则故障恢复时间久。min-pause设 30 秒,是防止上一个 checkpoint 还没结束就开下一个,造成两个快照并发执行,抢资源。max-concurrent-checkpoints必须设 1,并发 checkpoint 在某些 connector 上会导致写入乱序。tolerable-failed-checkpoints设 3,相当于给了连续三次失败的机会,不会因为一次网络抖动就重启整个作业。
RocksDB 的序列化开销比 Heap 状态后端高,但胜在状态规模上限大。我们有一个作业状态超过了 20GB,如果用堆内存状态后端,TaskManager 内存会溢出到无法挽回。还建议开启 state.backend.rocksdb.memory.managed(默认就是 true),让 Flink 帮 RocksDB 统一管理内存,避免它占用过多堆外内存。
3.3 Sink 改造:JDBC 连接器异常的高频坑
实时 ETL 的下游往往还是业务库或者分析型数据库。我们大量使用 Flink JDBC Connector 写 MySQL,这里基本上是整个链路踩坑最多的地方。
先列一个高频异常对照表:
| 异常现象 | 根因 | 解决方案 |
|---|---|---|
No suitable driver found for jdbc:mysql://... |
MySQL 驱动包不在 classpath 中 | 把 mysql-connector-java 放到 Flink lib 目录或打入作业 jar |
Connection is not available, request timed out after 30000ms |
连接池太小或写入吞吐超过了数据库能力 | 提高 sink 并行度、调大连接池、开启批量 flush |
Communications link failure |
MySQL 服务端断开空闲连接,或网络闪断 | JDBC URL 中设置 socketTimeout、connectTimeout,连接池开启 keepalive |
Duplicate entry 'xxx' for key 'PRIMARY' |
数据重放导致重复主键写入 | 改成 UPSERT 语义,如 INSERT ... ON DUPLICATE KEY UPDATE |
Flink SQL 建 JDBC 结果表时我会这样写:
sql复制CREATE TABLE order_etl_sink (
order_id BIGINT,
shop_id BIGINT,
amount DECIMAL(10, 2),
status INT,
PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:mysql://rds-host:3306/realtime_db',
'table-name' = 'order_etl_result',
'username' = 'realtime_writer',
'password' = '******',
'sink.buffer-flush.max-rows' = '1000',
'sink.buffer-flush.interval' = '5s',
'sink.max-retries' = '3'
);
这里说一个很容易踩的坑:PRIMARY KEY 后边的 NOT ENFORCED 不能省。Flink 不校验主键,但声明了主键后 JDBC 连接器会使用 upsert 语义。如果不声明主键,批量写入遇到重复键就会直接报错。sink.buffer-flush.max-rows 和 sink.buffer-flush.interval 是配套的,任意一个条件达到都会触发 flush。从实时性角度讲,5 秒的 flush 间隔可以接受;从数据库压力角度讲,1000 行一批这个量级也合理。
还有一点亲身经验:数据库账号权限要单独评估。实时任务很难重试,如果写入端被数据库的 max_allowed_packet 限制住,大批量 insert 会直接失败,而且报错信息容易让人误判成网络问题。上线之前先用脚本模拟同样量级的写入压一遍,别等到凌晨大促流量上来才发现。
4. 迁移过程中的实际问题排查实录
4.1 Datasophon 环境下 Flink 无法上传 Job 的排查链路
我们在用 Datasophon 统一管理 Hadoop 集群,它自带的 Flink 组件可以直接在界面上传作业。但上线第一个作业时就碰到问题:页面点击上传后一直转圈,过了一段时间报“uploaded job maybe lost, please try again”。
这个问题的排查过程我完整走了一遍,分享出来给后来者一个链路参考。
第一步,看 JobManager 日志。tail -f 翻到当时的时间点,能搜到和 upload 相关的报错,比如上传的 jar 临时文件写失败、或者 HTTP 请求在某个环节超时。这一步基本能确认问题出在本地文件系统还是 HDFS 上。
第二步,确认上传目录。Flink Web 上传作业时,jar 文件会先写到 Web 端的临时目录,该目录由 web.upload.dir 配置控制。默认在系统临时目录下,但如果平台把临时目录改到了 HDFS 或权限很严格的位置,就可能导致写失败。我们执行了这样的命令确认权限:
bash复制hadoop fs -ls /tmp/flink-web-upload
hadoop fs -chmod -R 777 /tmp/flink-web-upload
第三步,绕过 UI 直接用 REST API 验证。执行:
bash复制curl -v -F "jarfile=@/data/order-etl-job.jar" http://jobmanager-host:8081/jars/upload
如果 curl 能返回 200,说明 Web UI 本身没问题,问题在平台侧的上传链路或端口网络;如果 curl 也失败,说明 JobManager 的 HTTP 服务或底层目录有问题。我们最后发现是平台配置的临时目录落到了一个不存在且无权限的 HDFS 路径上,手动创建目录并授权后恢复。
第四步,排查 jar 包内部依赖。Flink 对同一个类来自不同 jar 的冲突非常敏感,尤其是 flink-shaded-hadoop、guava、fastjson 这类依赖。用户 jar 里如果捆了一份和平台 lib 目录重复的类,上传时校验可能通过,但 Job 启动阶段会抛各种兼容性异常。我们在作业 jar 里排除了绝大多数平台自带的依赖,只保留作业自身代码和必要的 connector。
4.2 CDC 接入阶段的数据一致性与延迟验证
我们用 Flink CDC 把 MySQL 订单库同步到 Kafka,这是整个 ETL 链路的上游。上线阶段遇到的问题比写代码本身多得多。
第一个问题是全量快照阶段延迟飙高。Flink CDC 老版本读大表时只能单并行度全表扫描,加上要同时消费 binlog,主库压力大,Kafka 消费 lag 一度跑到十几分钟。升级到增量快照特性后,把表按主键范围拆成 chunk 并行读取,全量阶段和增量阶段可以无缝衔接,这个问题才缓解。
第二个问题真是一不留神就中招:时区。MySQL 的 datetime 字段在 CDC 同步到下游后,展示出来的时间比原始值差了 8 小时。排查下来是 Debezium 内部对时间类型默认按 UTC 处理,而 MySQL 会话时区是东八区。解决方式是在 CDC 连接参数里显式指定时区,或者在下游 SQL 转换。我的建议是连接层直接统一,不要在每一条业务 SQL 里去 CONVERT_TZ,不然每个开发都可能写错。
第三个问题更隐蔽:全量和增量衔接阶段的数据重复。Flink CDC 快照完成后,binlog 消费位点不是精确对齐的,可能出现少量数据既在全量结果里也在增量结果里。处理手段和 Sink 阶段的幂等设计一样,下游用主键 upsert 吸收重复,同时通过两链路 diff 确认没有丢数据。
验证一致性的方法我列一下,都是可以直接抄的:
- 上游 MySQL 某张表统计
count(*)、sum 关键金额字段,和下游 Kafka 某个时间窗口内的记录数、金额求和做对比; - 用 binlog 中的
ts时间戳和下游写入时间做差值,计算单条数据的端到端延迟; - 定期跑一个全量对账作业,抽查最近 1 小时的明细数据是否完全一致。
4.3 反压问题定位:一次晚高峰假死复盘
反压是 Flink 运维里最常遇到的现象。我们有一个订单实时聚合作业,每天 21:00 促销时段指标掉得厉害,Web UI 上所有算子都变红,显示 BackPressure:HIGH。
我当时的第一步不是去看代码,而是看数据分布。回头翻 window 聚合的 keyBy,发现用的是 shop_id,也就是店铺 ID。促销时段某几个大店铺的订单量是平时的 50 倍,这几个 key 的数据全部落到了一个 subtask 上,单个 task 的处理能力变成了整个作业的瓶颈。
为了确认是热 key 而不是 JVM GC 问题,我在作业运行的 TaskManager 上抓了几份线程栈,发现 CPU 几乎都耗在 RocksDB 的 Get/Put 上,进一步坐实了状态热区问题。
解决办法是典型的“两阶段聚合”:先给 key 加一个随机后缀,让数据先分散到不同的 subtask 做一轮预聚合;再按真实 key 做最终聚合。
核心逻辑大约是:
java复制// 第一阶段:加随机后缀,打散热点
stream
.map(new KeyWithRandomSuffix())
.keyBy(r -> r.getShopId() + "_" + r.getSuffix())
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.reduce(new PreAggregate())
// 第二阶段:去掉后缀,按真实 key 聚合
.map(new RemoveSuffix())
.keyBy(r -> r.getShopId())
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.reduce(new FinalAggregate());
这里有一个必须注意的前提:只有聚合函数满足交换律和结合律(sum、count、max、min 这些)才能用两阶段聚合做等价改写。如果业务逻辑里涉及去重、求中位数、排序后取 TopN,就不能无脑用这个方案。
反压处理后,我还给所有窗口聚合任务加了一个准入保护:如果上游 Kafka 消费 lag 超过阈值,先丢弃一部分非核心维度数据,绝不让整条链路雪崩。
5. 全链路优化:资源调优与稳定性治理
5.1 并行度与资源配比:按源分区数反推
实时 ETL 作业的资源调优,我总结出一个比较实用的推导逻辑:一切从 source 的并行度开始。上游 Kafka topic 如果有 24 个分区,source 并行度设 24 是最直接的映射关系,一个 task 消费一个分区,不会有空转或分区抢读。
具体到我们一个典型作业:
- Kafka 分区数:24
- source 并行度:24(consumer group 尽量保持稳定)
- 普通 transform 算子:24,因为没有 shuffle,保持链式执行的连续性
- 窗口/聚合算子:12,因为聚合后数据量变少,并行度太高反而增加网络 shuffle 成本
- sink 写入 ClickHouse:4 到 8,取决于目标库的写入能力
TaskManager 的 slot 数我们设置为 4,每台机器内存 8GB 到 16GB。如果 slot 数设成 1 或 2,资源浪费太明显;设成 8 以上,单 TM 内部线程竞争又太激烈。内存分配容易踩坑的地方是 taskmanager.memory.process.size 和 JVM overhead 的比例。默认 overhead 是进程内存的 10%,如果你设了 8GB,实际留给堆内和托管内存的没有想象中那么多,状态增大的时候很容易触发频繁 GC。
另一个容易忽略的是网络缓冲。高吞吐场景下,如果 taskmanager.memory.network 比例偏低,会出现大量反压但 CPU 很低的情况。我们会在 Flink Web UI 的 TaskManager 指标里观察内存使用分布,再动态调 taskmanager.memory.network.min/max。
5.2 监控指标:别等作业挂了才发现
Flink 的自恢复机制(restart strategy)可以自动拉起失败作业,但这绝不意味着不需要监控。我把监控指标分成三组,放在 Prometheus + Grafana 上,任何一组异常都能提前发出告警。
第一组是吞吐指标:numRecordsInPerSecond、numRecordsOutPerSecond。这两个指标突然掉到 0 或陡降,基本说明作业停摆或数据流断掉。我还会配合 Kafka 消费 lag 来看,如果吞吐掉但 lag 涨,说明 source 消费能力出了问题。
第二组是 checkpoint 健康度:numberOfCompletedCheckpoints、lastCheckpointDuration、lastCheckpointSize。checkpoint 连续失败是作业崩溃的前兆,与其等作业最终失败,不如在连续 3 次失败时直接报警。我们的告警口径是:连续 2 次 checkpoint 失败或最后一次 checkpoint 时长超过 5 分钟就触发。告警之前还要确认是不是状态太大,而不是无脑调大超时时间。
第三组是资源与稳定性:restartCount、taskManagerStatusJvmGcTime、cpuUsage。Flink 作业重启一次,如果不是因为外部依赖恢复,大概率是代码里有没处理的脏数据或连接泄漏。这种问题靠无限重启掩盖,风险会越积越大。
Prometheus Reporter 的配置很简单,在 flink-conf.yaml 加两行:
yaml复制metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory
metrics.reporter.prom.port: 9249
然后让 Prometheus 抓取各节点 9249 端口,Grafana 里配上官方或社区模板就能看到比较完整的作业健康面。我们在上线初期花了整整两天把指标整理清楚,后面排查故障的效率高了好几倍。
5.3 状态增长、数据倾斜与作业治理
实时 ETL 跑久了,最容易被拖垮的是状态。State 只增不减,最终会把 RocksDB 撑爆,checkpoint 越来越慢,最终作业无法恢复。
给状态设置 TTL 是第一个必修动作。以 DataStream API 为例:
java复制StateTtlConfig ttlConfig = StateTtlConfig.newBuilder(Time.days(3))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
ValueStateDescriptor<OrderStatus> stateDescriptor = new ValueStateDescriptor<>("orderStatus", OrderStatus.class);
stateDescriptor.enableTimeStreaming(ttlConfig);
这里需要理解 TTL 的清理机制:它不是精确到秒删除的进程,而是在状态访问时判断是否过期,配合后台 Compaction 逐步清理。如果你的业务要求状态严格不过期,就不能依赖 TTL 做精确控制。还有一点,OnCreateAndWrite 和 OnReadAndWrite 两种更新策略要结合业务选,前者尽可能减少写入放大,后者适合经常被读取更新的状态。
数据倾斜的问题在 4.3 里说了两阶段聚合,这里补充一个更通用的思路:先定位热 key 的粒度。有的倾斜是业务天然造成的,同时要检查是不是 keyBy 选错了字段。比如你想统计店铺维度,但用了订单编号做 keyBy,再在窗口里做全局聚合,那并行度再高也没用。
最后提一句作业治理。每季度我们会对所有实时作业做一次盘点:有没有已经不用还占资源的作业、有没有状态只增不减的作业、有没有长时间没有 checkpoint 成功的作业。根据盘点结果做清理、合并、参数调整。这个习惯了之后,很多潜在的稳定性问题在爆发之前就被消化掉了,比事后救火省力太多。
我个人很深的体会是,Flink 给了你可控的状态和精确的流语义,但前提是你得按它的规则来:配好 checkpoint、管好状态 TTL、关注反压指标、给监控设好阈值。从 Spark Streaming 换到 Flink 不是换个 API 那么轻巧,而是一次对实时链路完整治理逻辑的重建。按我这次的经验,先选一个小而重要的作业跑通全链路,再逐步扩大范围,比一次性把几十个作业全迁过去稳妥得多。
