POJO:以下示例数据模型
java
public class OrderEvent {
public String orderId; // 业务主键
public String userId;
public String region;
public long amount; // 单位:分,避免Double金额误差
public long eventTime; // 业务发生时间,epoch毫秒
public String eventId; // 全局事件ID,用于幂等与去重
public OrderEvent() {} // POJO需要公有无参构造
}
一、Event Time、Watermark与时间语义
1、用Processing Time冒充业务时间,重跑后结果改变
业务场景:
按订单"发生时间"统计每5分钟销售额,但代码直接使用处理时间窗口。
危险写法:
java
stream
.keyBy(OrderEvent::getRegion)
.window(TumblingProcessingTimeWindows.of(Time.minutes(5)))
.sum("amount");
问题示例:
订单A实际发生于`10:00:01`,因Kafka积压,直到`10:06:00`才被Flink处理:
Processing Time:计入`10:05~10:10`窗口;
业务期望:计入`10:00~10:05`窗口;
今天跑和明天重跑,结果可能不同
推荐写法:
java
DataStream<OrderEvent> withWatermark = stream.assignTimestampsAndWatermarks(
WatermarkStrategy
.<OrderEvent>forBoundedOutOfOrderness(Duration.ofMinutes(2))
.withTimestampAssigner((event, previousTimestamp) -> event.eventTime)
);
DataStream<OrderEvent> result = withWatermark
.keyBy(event -> event.region)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.reduce((a, b) -> {
OrderEvent out = new OrderEvent();
out.region = a.region;
out.amount = a.amount + b.amount;
out.eventTime = a.eventTime;
return out;
});
避坑结论:
1)需要按业务发生时间统计、可重放且结果稳定:优先Event Time。
2)只需要"每隔几秒做一次系统动作":Processing Time更合适。
3)不要在一条链路中随意混用两种时间,却不解释其业务含义。
2、时间戳单位、时区错误,Watermark突然失控
业务场景:
上游部分事件使用秒级时间戳,部分使用毫秒级时间戳。
错误示例:
正确事件:eventTime = 1735689600123 // 毫秒
异常事件:eventTime = 1735689600 // 秒,被当成1970年附近
未来事件:eventTime = 1735689600123000 // 微秒,被当成遥远未来
若未来事件把Watermark推到很远的未来,大量正常事件会瞬间变成"迟到数据"。
推荐写法:入口校验与隔离
javaOutputTag<OrderEvent> badTimeTag = new OutputTag<>("bad-event-time"){}; SingleOutputStreamOperator<OrderEvent> valid = stream.process( new ProcessFunction<OrderEvent, OrderEvent>() { @Override public void processElement(OrderEvent e, Context ctx, Collector<OrderEvent> out) { long now = ctx.timerService().currentProcessingTime(); boolean tooOld = e.eventTime < now - Duration.ofDays(7).toMillis(); boolean tooFuture = e.eventTime > now + Duration.ofDays(1).toMillis(); if (e.eventTime <= 0 || tooOld || tooFuture) { ctx.output(badTimeTag, e); //隔离,不让异常时间污染Watermark } else { out.collect(e); } } } ); valid.getSideOutput(badTimeTag).addSink(/* 异常事件存储/告警 */);
避坑结论:
1)入口统一epoch毫秒、时区和字段含义。
2)监控最小/最大事件时间、未来时间戳数量、迟到率。
3)不要用`System.currentTimeMillis()`静默替换异常业务时间,否则重放语义仍会变化;应隔离或按明确规则修正。
3、一个空闲Kafka分区拖住整个Watermark
业务场景:
Kafka Topic有12个分区,其中1个分区低峰期长期无数据。其他分区已经到12:00,窗口却迟迟不触发。
根因:
多分区、多输入场景的Watermark通常受最慢输入影响。一个分区不推进,就可能拖住整体事件时间。
推荐写法:
java
WatermarkStrategy<OrderEvent> strategy = WatermarkStrategy
.<OrderEvent>forBoundedOutOfOrderness(Duration.ofMinutes(2))
.withTimestampAssigner((event, ts) -> event.eventTime)
.withIdleness(Duration.ofMinutes(1)); // 空闲输入不再长期拖住Watermark
例子:
分区 P0 Watermark = 11:58
分区 P1 Watermark = 11:59
分区 P2 无数据,停留在 10:00
=> 整体可能停在 10:00
P2 被标记空闲后:
=> 活跃输入可继续按11:58推进
副作用:
P2恢复来数后,如果它带来的仍是10:00的旧事件,这些事件可能立即迟到。`withIdleness`不是免费修复,而是对延迟与新鲜度的权衡。
4、Watermark不推进,却误以为是窗口或Sink故障
排查代码:主动打印时间与Watermark
生产环境不要逐条打印,以下只用于压测或抽样排障。
java
stream.process(new ProcessFunction<OrderEvent, OrderEvent>() {
@Override
public void processElement(OrderEvent e, Context ctx, Collector<OrderEvent> out) {
long eventTimeWatermark = ctx.timerService().currentWatermark();
if (e.eventTime < eventTimeWatermark) {
// 可增加迟到计数Metric,而不是逐条info日志
}
out.collect(e);
}
});
必查指标:
1)每个Source subtask的输出Watermark,而非只看Job聚合值;
2)每个关键算子的输入/输出Watermark;
3)是否存在无数据分区、被过滤成空流的输入、Union的静默一路;
4)时间戳是否出现秒/毫秒、时钟回拨或未来时间。
避坑结论:
窗口不输出时先验证Watermark,不要先缩短窗口、加并行度或重启作业。
5、Event Time Timer与Processing Time Timer混用
错误理解:
"Watermark已经推进,为什么Processing Time Timer没触发?"因为它们不属于同一时间域。
代码对照:
java
public class TimerFunction extends KeyedProcessFunction<String, OrderEvent, String> {
@Override
public void processElement(OrderEvent e, Context ctx, Collector<String> out) {
// 由事件时间/Watermark驱动
ctx.timerService().registerEventTimeTimer(e.eventTime + 60_000);
// 由TaskManager机器时间驱动
ctx.timerService().registerProcessingTimeTimer(
ctx.timerService().currentProcessingTime() + 60_000
);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) {
out.collect("timer fired at " + timestamp + ", key=" + ctx.getCurrentKey());
}
}
具体区别:
1)Kafka暂停来数:Event Time Watermark可能停止,Event Time Timer可能不触发;Processing Time Timer仍可按机器时间触发。
2)作业暂停后恢复:两类Timer的触发体验不同。
3)补录历史数据:Processing Time结果与补录时点强相关,Event Time更能保持业务口径。
二、窗口、迟到数据与Trigger
1、认为Watermark越过后,早到事件绝不可能再来
业务场景:
最大乱序设置为2分钟,但实际跨系统补录延迟达到30分钟。
java
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofMinutes(2));
例子:
当前Watermark = 10:10
窗口 [10:00, 10:05) 已触发
此时来了一条eventTime = 10:03的订单
=> 它属于迟到数据,而不是系统违反Watermark规则
避坑结论:
Watermark是进度估计,不是数据完整性的物理保证。必须显式设计迟到数据的丢弃、修正或补偿策略。
2、只设置`allowedLateness`,下游却把修正结果当重复数据
推荐写法:主结果+迟到侧输出
java
OutputTag<OrderEvent> lateTag = new OutputTag<>("late-orders"){};
SingleOutputStreamOperator<OrderEvent> aggregated = stream
.keyBy(e -> e.region)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(30))
.sideOutputLateData(lateTag)
.reduce((a, b) -> {
OrderEvent out = new OrderEvent();
out.region = a.region;
out.amount = a.amount + b.amount;
return out;
});
aggregated.getSideOutput(lateTag).addSink(/* 迟到补偿/审计Sink */);
例子:
10:05 首次输出:华东= 100元
10:20 一条迟到订单+50元
若仍在allowedLateness内:窗口可能再次输出 华东=150元
下游必须正确表达更新
sql
-- MySQL示例:region + window_start应是唯一键
INSERT INTO region_window_amount(region, window_start, amount)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE amount = VALUES(amount);
避坑结论:
1)允许迟到意味着窗口可能多次输出修正。
2)下游只Append时会看到"重复窗口";应按窗口主键覆盖或处理Changelog。
3)侧输出只是拿到迟到事件,不会自动修复已经输出到第三方系统的主结果。
3、`allowedLateness`配得很大,状态无限接近"永久保留"
危险配置:
java
.allowedLateness(Time.days(30))
影响:
1)窗口状态与Timer长时间保留;
2)Checkpoint增大;
3)RocksDB读写与恢复时间上升;
4)大量窗口可能在相近时间清理,引发IO抖动。
更合理的设计:
主窗口:允许迟到10分钟,承接绝大多数修正
超过10分钟:进入迟到侧输出
极端迟到:离线补偿或按业务事件ID做对账修正
不要用一个30天的在线窗口状态,解决所有历史补数问题。
4、为获取窗口信息,误用全窗口明细缓存
不推荐:把所有明细留在窗口中
java
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.process(new ProcessWindowFunction<OrderEvent, Long, String, TimeWindow>() {
@Override
public void process(String key, Context ctx, Iterable<OrderEvent> elements,
Collector<Long> out) {
long sum = 0;
for (OrderEvent e : elements) sum += e.amount;
out.collect(sum);
}
});
推荐:增量聚合+窗口元信息
java
public class SumAgg implements AggregateFunction<OrderEvent, Long, Long> {
@Override public Long createAccumulator() { return 0L; }
@Override public Long add(OrderEvent e, Long acc) { return acc + e.amount; }
@Override public Long getResult(Long acc) { return acc; }
@Override public Long merge(Long a, Long b) { return a + b; }
}
stream.keyBy(e -> e.region)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new SumAgg(),
new ProcessWindowFunction<Long, String, String, TimeWindow>() {
@Override
public void process(String region, Context ctx, Iterable<Long> values,
Collector<String> out) {
long sum = values.iterator().next();
out.collect(region + "," + ctx.window().getStart() + "," + sum);
}
});
避坑结论:
需要窗口开始/结束时间,不等于需要缓存所有窗口明细。明细型ProcessWindowFunction与增量AggregateFunction的状态成本完全不同。
5、滑动窗口造成状态和计算份数放大
代码示例:
java
//每条事件可能属于60个窗口:1小时窗口,每1分钟滑动
.window(SlidingEventTimeWindows.of(Time.hours(1), Time.minutes(1)))
例子:
一条`10:30`的事件会落入从约`09:31~10:31`到`10:30~11:30`的多个窗口。若每个窗口保存明细或大聚合对象,状态和Timer会显著放大。
优化思路:
先做1分钟小窗口预聚合
=> 再将60个小窗口汇总成1小时结果
=> 用延迟、状态和计算量之间的实测结果选择方案
6、自定义Trigger中FIRE、PURGE、clear混淆
示例:提前触发但暂不清理窗口
java
public class EarlyCountTrigger extends Trigger<OrderEvent, TimeWindow> {
@Override
public TriggerResult onElement(OrderEvent e, long timestamp, TimeWindow window,
TriggerContext ctx) {
if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
return TriggerResult.FIRE_AND_PURGE;
}
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onProcessingTime(long time, TimeWindow window,
TriggerContext ctx) {
return TriggerResult.FIRE; //提前输出,不立即清窗口内容
}
@Override
public TriggerResult onEventTime(long time, TimeWindow window,
TriggerContext ctx) {
return time >= window.maxTimestamp()
? TriggerResult.FIRE_AND_PURGE : TriggerResult.CONTINUE;
}
@Override
public void clear(TimeWindow window, TriggerContext ctx) {
//清理本Trigger注册的状态/Timer,不等于任意业务状态都自动清理
}
}
避坑结论:
1)`FIRE`:触发计算/输出。
2)`PURGE`:清理窗口内容。
3)`clear()`:主要用于Trigger自身资源清理。
4)提前输出多次+最终输出一次,需要下游能够处理修正或明确只取最终值。
三、Checkpoint、Exactly-Once与外部副作用
1、只开启Checkpoint,就宣称端到端Exactly-Once
基础配置:
java
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000, CheckpointingMode.EXACTLY_ONCE);
CheckpointConfig cp = env.getCheckpointConfig();
cp.setCheckpointTimeout(10 * 60_000);
cp.setMinPauseBetweenCheckpoints(30_000);
cp.setMaxConcurrentCheckpoints(1);
cp.enableExternalizedCheckpoints(
CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
);
反例代码:在map中调用不可回滚接口
java
stream.map(e -> {
paymentClient.pay(e.orderId, e.amount); //故障重放可能再次扣款
return e;
});
即使Flink状态Exactly-Once,支付接口仍可能执行多次。
推荐:幂等副作用
java
// 外部系统必须以eventId/orderId做唯一约束或幂等令牌
paymentClient.payIdempotently(e.eventId, e.orderId, e.amount);
避坑结论:
Exactly-Once要同时验证Source重放、Flink状态、Sink提交、下游可见性和用户副作用。Checkpoint只是其中一环。
2、Kafka事务Sink配了Exactly-Once,下游仍读到回滚数据
Flink Kafka Sink示例:
java
KafkaSink<String> sink = KafkaSink.<String>builder()
.setBootstrapServers("kafka:9092")
.setRecordSerializer(
KafkaRecordSerializationSchema.builder()
.setTopic("order-result")
.setValueSerializationSchema(new SimpleStringSchema())
.build()
)
.setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
// 同一逻辑作业跨重启保持稳定;不同逻辑作业不要共用,避免互相fencing
.setTransactionalIdPrefix("order-agg-prod-01")
// 应覆盖合理的最慢Checkpoint与恢复窗口,不能照抄此值
.setProperty("transaction.timeout.ms", "900000")
.build();
result.sinkTo(sink);
下游消费者也必须配合:
isolation.level=read_committed
例子:
Flink预写一批事务消息
Checkpoin未完成,事务最终中止
下游read_uncommitted:可能读到这批最终不可见的数据
下游read_committed:只读已提交数据
避坑结论:
事务型Sink不等于下游自动一致。还要检查事务超时、transactional.id前缀、旧作业zombie、消费隔离级别与Checkpoint失败时长。
3、JDBC普通INSERT重试导致重复数据
危险写法:
sql
INSERT INTO order_result(order_id, amount) VALUES (?, ?)
故障发生在"数据库已写入、Flink尚未确认"之间时,重试可能再次插入。
推荐:业务主键+幂等Upsert
java
JdbcExecutionOptions execOptions = JdbcExecutionOptions.builder()
.withBatchSize(500)
.withBatchIntervalMs(1000)
.withMaxRetries(3)
.build();
JdbcConnectionOptions connOptions = new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl("jdbc:mysql://mysql:3306/demo")
.withDriverName("com.mysql.cj.jdbc.Driver")
.withUsername("flink")
.withPassword("***")
.build();
Sink<OrderEvent> jdbcSink = JdbcSink.sink(
"INSERT INTO order_result(order_id, amount) VALUES (?, ?) " +
"ON DUPLICATE KEY UPDATE amount = VALUES(amount)",
(ps, e) -> {
ps.setString(1, e.orderId);
ps.setLong(2, e.amount);
},
execOptions,
connOptions
);
result.addSink(jdbcSink);
额外坑点:
1)数据库表必须真的有唯一键,否则Upsert无效。
2)多并行任务对同一主键无序更新,可能旧值覆盖新值;必要前先 `keyBy(orderId)`。
3)重试幂等不代表所有复杂多表写入都能自动Exactly-Once。
4)连接池、批量和重试会放大DB压力,需要联合限流。
4、Redis INCR在恢复重放后重复累加
危险代码:
java
stream.map(e -> {
jedis.incrBy("sales:" + e.region, e.amount);
return e;
});
例子:
事件E001:金额100
第一次执行INCR成功,随后作业失败
恢复后E001再执行一次
=> Redis多算100
可选方案:事件ID去重+幂等更新
方案 A:结果表按region/window覆盖最终值,而非增量INCR
方案 B:Redis Lua同时检查eventId是否已处理并更新,但去重集合要有生命周期
方案 C:用可靠数据库唯一约束/幂等表承载结果,Redis只做可重建缓存
避坑结论:
Lua脚本单次原子执行,不等于跨Flink Checkpoint的端到端一致性。
5、Checkpoint超时,第一反应是无限调大Timeout
错误处置:
java
cp.setCheckpointTimeout(60 * 60_000); // 把问题拖一小时,而非修复
正确排查顺序
1)Checkpoint历史中哪个subtask最慢?
2)慢在Barrier对齐、同步快照,还是异步上传?
3)是否只有个别subtask慢?若是,优先查数据倾斜。
4)是否Sink降速导致全链路反压?
5)状态大小和远端存储p99是否异常?
可能的修复:
java
// 示例:降低无效大状态,而不是单纯放大超时
ValueStateDescriptor<Long> descriptor =
new ValueStateDescriptor<>("region-sum", Long.class);
// 只保存必要的聚合值,不保存完整订单列表
避坑结论:
超时是保护参数,不是性能优化参数。长期反压下增大超时可能让Lag、事务与状态进一步堆积。
6、Unaligned Checkpoint被当成反压修复按钮
示例:
java
cp.enableUnalignedCheckpoints();
适用场景:
严重反压时,Barrier对齐等待过长,Unaligned Checkpoint可能让快照更容易完成。
代价:
正常Checkpoint:主要保存算子状态
Unaligned Checkpoint:还可能保存in-flight数据
=> Checkpoint体积、存储IO、恢复复杂度可能增加
避坑结论:
它改变的是快照路径,不会让慢Sink变快,也不会消除热Key。应作为明确权衡后的配置,而不是默认遮羞布。
7、多路输入Barrier对齐导致Checkpoint失败
代码示例:
java
DataStream<OrderEvent> orders = ...;
DataStream<OrderEvent> repairs = ...; // 低流量补单流
DataStream<OrderEvent> connected = orders.connect(repairs)
.process(new CoProcessFunction<OrderEvent, OrderEvent, OrderEvent>() {
@Override public void processElement1(OrderEvent e, Context c, Collector<OrderEvent> out) {
out.collect(e);
}
@Override public void processElement2(OrderEvent e, Context c, Collector<OrderEvent> out) {
out.collect(e);
}
});
例子:
补单流长时间没有数据或某通道严重反压,多输入算子Barrier对齐等待变长。表面是Checkpoint失败,根因可能在输入和反压。
处置:
1)检查每路输入进度和反压;
2)减少不必要的Connect/Union;
3)低流量流需要明确Watermark/心跳策略;
4)最后再评估Unaligned Checkpoint与超时调整。
四、状态、Savepoint、TTL与升级恢复
1、不显式设置UID,改代码后Savepoint状态对不上
危险写法:
java
stream
.map(new EnrichFunction())
.keyBy(e -> e.orderId)
.process(new OrderStateFunction())
.addSink(sink);
依赖自动算子ID,拓扑、算子链或代码结构变化后,历史状态可能无法匹配。
推荐写法:
java
stream
.map(new EnrichFunction())
.name("order-enrich")
.uid("order-enrich-v1")
.keyBy(e -> e.orderId)
.process(new OrderStateFunction())
.name("order-stateful-process")
.uid("order-stateful-process-v1")
.addSink(sink)
.name("order-result-sink")
.uid("order-result-sink-v1");
最危险的现象:
作业恢复后显示`RUNNING`,但某个算子没有匹配到旧状态,以空状态启动。进程没有报错,业务结果却悄悄错误。
示例:
bash
# 停止并制作Savepoint:命令参数以实际版本为准
flink stop --savepointPath hdfs:///flink/savepoints <jobId>
# 从Savepoint恢复
flink run -s hdfs:///flink/savepoints/savepoint-xxx app.jar
避坑结论:
恢复成功必须配合状态匹配检查和业务对账,不能只看作业状态。
2、State Descriptor改名或改类型,旧状态无法按预期读取
原代码:
java
ValueStateDescriptor<Long> sumDesc =
new ValueStateDescriptor<>("order-sum", Long.class);
危险修改:
java
// 名称改变,且值类型从Long改为String
ValueStateDescriptor<String> sumDesc =
new ValueStateDescriptor<>("order-total-text", String.class);
这不是普通变量重构,而是持久化状态Schema变化。
推荐:兼容升级与显式迁移
兼容变更:保持算子UID、状态名称、键和值序列化兼容
不兼容变更:
制作Savepoint
使用状态迁移工具/离线处理生成新状态
或新旧作业双跑并对账
验证后切换
避坑结论:
"代码能编译""能从文件启动""状态名称一样"都不能单独证明状态兼容。
3、修改POJO字段导致状态序列化兼容问题
原状态对象:
java
public class OrderState {
public String orderId;
public long amount;
public OrderState() {}
}
危险修改:
java
public class OrderState {
public String orderNo; // orderId改名
public String amount; // long改String
public OrderState() {}
}
更稳妥方式:
1)将状态Schema当作数据库表结构管理;
2)字段变更做序列化兼容测试;
3)重要状态优先考虑有明确Schema演进能力的格式;
4)用生产Savepoint在隔离环境恢复,不拿生产直接试错。
最小恢复:
bash
# 用旧版本作业生成Savepoint,再用新版本作业在测试集群恢复
flink run -s hdfs:///flink/savepoints/savepoint-xxx new-app.jar
# 恢复后至少核对:
# 1.有状态算子是否恢复
# 2.抽样Key的状态值
# 3.同一批输入下新旧结果是否一致
4、给Join/去重状态随意加TTL,结果静默错误
危险代码:去重只记1小时
java
StateTtlConfig ttl = StateTtlConfig.newBuilder(Time.hours(1))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
ValueStateDescriptor<Boolean> seenDesc =
new ValueStateDescriptor<>("seen-event", Boolean.class);
seenDesc.enableTimeToLive(ttl);
例子:
10:00收到E001,去重状态记录seen=true
13:00上游重放E001
状态早已过期=>被当成新事件再次处理
推荐:TTL必须覆盖业务去重窗口
// 仅示例:业务保证同一eventId最多3天内重放,并额外留余量
StateTtlConfig ttl = StateTtlConfig.newBuilder(Time.days(7))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
完整去重示例:
java
public class DeduplicateFunction
extends KeyedProcessFunction<String, OrderEvent, OrderEvent> {
private transient ValueState<Boolean> seen;
@Override
public void open(Configuration parameters) {
ValueStateDescriptor<Boolean> desc =
new ValueStateDescriptor<>("seen-event", Boolean.class);
// TTL应按业务最大重放跨度设置,而非按"希望状态小一点"设置
seen = getRuntimeContext().getState(desc);
}
@Override
public void processElement(OrderEvent e, Context ctx, Collector<OrderEvent> out)
throws Exception {
if (seen.value() == null) {
seen.update(true);
out.collect(e);
}
}
}
// 以eventId为Key,才能对该事件ID做状态去重
stream.keyBy(e -> e.eventId).process(new DeduplicateFunction());
避坑结论:
TTL是"允许遗忘"的业务决策。逻辑过期也不等于RocksDB物理空间立即释放。
5、去重Key设计错误,状态去重看起来没生效
错误代码:
java
// 想按eventId去重,却先按region分区
stream.keyBy(e -> e.region).process(new DeduplicateFunction());
同一个`eventId`若出现在不同region分区,各分区都有自己的状态,无法全局去重。
正确代码:
java
stream.keyBy(e -> e.eventId).process(new DeduplicateFunction());
若后续需要按region聚合,再去重后重新分区:
java
DataStream<OrderEvent> distinct = stream
.keyBy(e -> e.eventId)
.process(new DeduplicateFunction());
distinct.keyBy(e -> e.region)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new SumAgg());
避坑结论:
Keyed State的作用域是"当前Key+算子"。去重Key、Join Key、聚合Key不一致时,必须明确每一步重分区的语义。
6、并行度可以改,但忽略最大并行度与Key Group
例子:
上线时并行度 = 8
业务增长后希望直接扩到4096
如果最大并行度/Key Group设计不足,扩缩容可能受限或无法按预期恢复
配置示例:
java
env.setParallelism(32);
env.setMaxParallelism(1024); // 上线前按增长预估,不是事后随意热改
避坑结论:
1)最大并行度影响Keyed State的Key Group映射。
2)运行很久后再改变基础分桶设计,应按状态迁移项目处理。
3)扩容前还要检查Kafka分区、Sink主键冲突、外部DB容量,不是只改Flink数字。
7、把自动Checkpoint当永久备份,取消作业后无法恢复
配置示例:
java
cp.enableExternalizedCheckpoints(
CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
);
仍然不够原因:
1)文件保留不等于Schema兼容;
2)文件保留不等于元数据完整;
3)文件保留不等于外部Kafka事务/Sink状态可配套恢复;
4)Checkpoint与Savepoint的运维用途不同。
发布流程:
bash
# 1.对旧作业制作Savepoint
# 2.新作业从Savepoint启动
# 3.新旧结果对账
# 4.验证通过后再让旧作业退场
# 5.回滚也要验证旧代码能否恢复新作业期间产生的状态
8、切换状态后端只改配置,不验证恢复与磁盘
配置示意:
java
// 具体类名/依赖随版本变化,重点是切换需要完整评估
env.setStateBackend(new EmbeddedRocksDBStateBackend(true));
env.getCheckpointConfig().setCheckpointStorage(
"hdfs:///flink/checkpoints"
);
具体风险:
原Heap状态:小、快、主要在JVM堆
切RocksDB后:需要本地磁盘、Cache、Compaction、增量快照与文件管理
上线验证:
1)Savepoint跨目标组合恢复是否受支持;
2)恢复时状态下载时间;
3)本地磁盘容量、IO与inode;
4)p99状态访问延迟;
5)Checkpoint文件是否因旧文件引用而未按预期缩小。
五、反压、数据倾斜与性能
1、看到反压就全员加并行度,瓶颈却纹丝不动
场景代码:
java
env.setParallelism(64); // 原来16,直接乘4
例子:
Kafka只有8个分区
Source的有效并行上限受分区约束
或者80%数据都落在userId=A
=> 加到64也无法拆分单个热Key的串行处理
正确判断:
高backPressured+低busy:本任务多在被下游卡住
高busy+输入积压:本任务可能是瓶颈
只有个别subtask忙:优先查倾斜
Source无积压但Sink慢:查外部系统,而非Source并行度
2、热Key直接`keyBy`,单subtask被打爆
原始代码:
java
stream.keyBy(e -> e.userId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new SumAgg());
例子:
userId=A占80%流量
其余99个userId占20%
无论总并行度多少,A的同一窗口聚合仍可能集中于少数任务
可交换/可结合聚合:加盐两阶段聚合
java
int saltCount = 16;
DataStream<OrderEvent> salted = stream.map(e -> {
OrderEvent copy = e; // 生产建议使用独立DTO,避免随意修改原对象
copy.userId = e.userId + "#" + Math.floorMod(e.eventId.hashCode(), saltCount);
return copy;
});
DataStream<OrderEvent> partial = salted
.keyBy(e -> e.userId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.reduce((a, b) -> {
OrderEvent out = new OrderEvent();
out.userId = a.userId;
out.amount = a.amount + b.amount;
out.eventTime = a.eventTime;
return out;
});
DataStream<OrderEvent> global = partial
.map(e -> {
e.userId = e.userId.substring(0, e.userId.lastIndexOf('#'));
return e;
})
.keyBy(e -> e.userId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.reduce((a, b) -> {
OrderEvent out = new OrderEvent();
out.userId = a.userId;
out.amount = a.amount + b.amount;
return out;
});
重要限制:
加盐适用于可拆分、可合并的聚合。以下语义不能机械套用:
1)严格全局顺序;
2)精确一次去重;
3)不可拆分的 Join;
4)某些TopN、Session、非代数聚合。
3、Join倾斜盲目加盐,匹配语义被破坏
原始Join:
java
orders.keyBy(o -> o.userId)
.intervalJoin(payments.keyBy(p -> p.userId))
.between(Time.minutes(-5), Time.minutes(5))
.process(new OrderPaymentJoin());
错误思路:
只给订单侧 `userId`随机加`#0~#15`,支付侧不改:
订单userId=A#3
支付userId=A
=> 永远不在同一个Key上,Join大量漏匹配
正确处理原则:
1)两侧必须采用可对应的Key映射;
2)一侧复制、另一侧拆分可能显著放大状态与输出;
3)热点维值可评估广播或单独处理;
4)Interval Join还要保留所有合法时间范围内的状态。
4、异步IO提高并发,却把维表数据库打垮
危险表现:
java
AsyncDataStream.unorderedWait(
stream,
new RichAsyncFunction<OrderEvent, EnrichedOrder>() {
@Override
public void asyncInvoke(OrderEvent e, ResultFuture<EnrichedOrder> future) {
dbClient.query("SELECT ... WHERE user_id = '" + e.userId + "'")
.thenApply(row -> enrich(e, row))
.thenAccept(future::complete);
}
@Override public void timeout(OrderEvent e, ResultFuture<EnrichedOrder> future) {
future.completeExceptionally(new TimeoutException("dim query timeout"));
}
},
5, TimeUnit.SECONDS,
10000 // 并发过大,数据库连接池和QPS无法承受
);
推荐思路:
java
int asyncCapacity = 100; // 根据DB可承载QPS、连接池、p99延迟实测确定
AsyncDataStream.unorderedWait(
stream,
new DimAsyncFunction(),
3, TimeUnit.SECONDS,
asyncCapacity
);
避坑点:
1)`unorderedWait`可能改变输出顺序;
2)超时重试必须幂等;
3)异步并发>连接池容量时仍会排队;
4)需要事件发生时维值,普通Lookup查"当前值"可能语义错误;
5)不要自己再开无界线程池绕开Flink生命周期。
5、自己开线程池异步处理,异常、关闭与Checkpoint全失控
危险代码:
java
public class BadAsyncMap extends RichMapFunction<OrderEvent, OrderEvent> {
private ExecutorService pool;
@Override public void open(Configuration p) {
pool = Executors.newCachedThreadPool(); // 无界线程风险
}
@Override public OrderEvent map(OrderEvent e) {
pool.submit(() -> httpClient.send(e)); // 外部副作用脱离Flink控制
return e;
}
// 没有关闭pool,异常也未传播给Flink
}
推荐:
1)使用Flink Async I/O或支持语义的Sink;
2)外部资源在`open/close`管理;
3)有界队列、有界并发、明确超时;
4)异常必须进入Flink失败/重试机制;
5)副作用需要幂等。
6、窗口末尾集中触发,平均指标正常但p99爆炸
代码示例:
java
// 所有用户都在整点触发1小时窗口
.window(TumblingEventTimeWindows.of(Time.hours(1)))
例子:
00:00~00:59 平均CPU30%
01:00 大量窗口同时触发、RocksDB读取、Sink写入
=> p99延迟、Checkpoint、DB锁等待同时飙升
处置思路:
1)增量聚合,减少窗口触发时读取明细;
2)Sink批写与限流,避免瞬间打爆外部系统;
3)检查热点Key和大窗口份数;
4)可业务接受时使用提前触发/持续更新,但下游必须支持修正;
5)压测必须覆盖整点,而非只测平稳中段。
六、内存、Slot、算子链与容器
1、JVM堆还有余量,容器却被OOMKilled
错误认知:
-Xmx没打满 => 不可能内存不足
实际容器总内存还包括:
JVM Heap
Metaspace
Direct Memory
Network Memory
Managed/RocksDB/Native Memory
线程栈、JIT、第三方 Native库
容器安全余量
配置示意:
XML
# Flink配置示例,具体名称和值按部署版本与资源模型确定
taskmanager.memory.process.size: 8g
taskmanager.memory.task.heap.size: 2g
taskmanager.memory.managed.size: 2g
排查建议:
bash
# 看容器退出原因,而不只看Java日志
kubectl describe pod <taskmanager-pod>
# 如出现OOMKilled,再结合:
# - Direct Memory指标
# - RocksDB内存与本地文件
# - Native Memory Tracking
# - Slot数和每TM实际任务数
避坑结论:
不要把所有剩余内存都分给RocksDB或堆。网络、Direct、Native 和故障时的临时内存也需要预算。
2、RocksDB Cache越大越好,结果挤压其他内存
危险调优方式:
看到状态访问慢
=> 不断加 Managed Memory/Block Cache
=> Network/Heap/容器余量不足
=> Checkpoint 和 GC 反而恶化
正确顺序:
java
// 先减少状态:错误示例保存整个订单列表
ListStateDescriptor<OrderEvent> allOrders =
new ListStateDescriptor<>("all-orders", OrderEvent.class);
// 更优:业务允许时只保存必要聚合值
ValueStateDescriptor<Long> sum =
new ValueStateDescriptor<>("order-sum", Long.class);
避坑结论:
先优化状态模型和访问模式,再调Cache、Write Buffer、Compaction,最后才加资源。
3、全局断开算子链,网络和序列化开销暴增
危险操作:
java
env.disableOperatorChaining();
为什么会这么做:
算子链内多个算子作为一个任务运行,定位慢点和独立扩缩容更困难,于是一刀切断链。
更合理:局部拆分重算子
java
stream
.map(new LightMap())
.uid("light-map-v1")
.keyBy(e -> e.orderId)
.process(new HeavyRocksDbFunction())
.uid("heavy-state-v1")
.startNewChain() // 只对确有必要的边界拆链
.addSink(sink)
.uid("heavy-sink-v1");
避坑结论:
链是性能与可诊断性的权衡。修改链还可能影响自动算子ID,生产恢复必须验证。
4、Slot Sharing让重状态任务集中到一个TM
配置代码:
java
stream.map(new LightMap())
.slotSharingGroup("light");
stream.keyBy(e -> e.orderId)
.process(new HeavyStateFunction())
.slotSharingGroup("heavy-state");
适用场景:
1)重CPU与重内存任务需要隔离;
2)避免单TM RocksDB/磁盘过热;
3)控制故障爆炸半径。
代价:
资源碎片和调度约束可能增加。应结合Slot数、每Slot资源、实际任务分布验证,而不是分组越多越好。
七、Kafka、CDC与常见Connector
1、无状态重启+从最新位点消费,静默跳过故障期间数据
Kafka Source示例:
java
KafkaSource<OrderEvent> source = KafkaSource.<OrderEvent>builder()
.setBootstrapServers("kafka:9092")
.setTopics("orders")
.setGroupId("flink-order-job")
.setStartingOffsets(OffsetsInitializer.latest()) // 高危:只从最新开始
.setValueOnlyDeserializer(new OrderEventDeserializer())
.build();
DataStream<OrderEvent> stream = env.fromSource(
source, WatermarkStrategy.noWatermarks(), "orders-source"
);
例子:
作业02:00故障,05:00才被人发现
运维删除旧状态并以latest启动
=> 02:00~05:00的订单可能被静默跳过
=> 作业运行非常健康,业务结果却少了三小时
启动策略:
1)正常故障恢复:从有效Checkpoint/Savepoint恢复;
2)确需指定位点:按时间戳/明确Offset启动,并记录补数范围;
3)修改Group ID、Topic、起始策略前评估重复与漏数;
4)启动后做Source/Sink条数与关键业务指标对账。
java
// 按明确时间点启动的示意,不等于自动解决端到端一致性
.setStartingOffsets(OffsetsInitializer.timestamp(1735689600000L));
2、Kafka分区扩容后,下游并行度和Watermark行为变化
例子:
原Topic:8分区,Flink Source并行度16
=> 部分Source子任务可能本来就没有分区
扩容到32分区:
=> 是否自动发现取决于Source配置
=> 新分区初期低流量/空闲,可能影响Watermark
=> 旧数据的历史Key分布不会因扩分区自动重排
分区发现:
java
KafkaSource.<OrderEvent>builder()
// 具体API与默认值以版本为准
.setProperty("partition.discovery.interval.ms", "60000");
避坑结论:
Kafka扩容是Source、Watermark、消费位点、下游并行度的联合变更,不是Kafka单侧操作。
3、Upsert-Kafka的Key设计错误,更新变追加
SQL示例:
sql
CREATE TABLE order_upsert (
order_id STRING,
amount BIGINT,
update_time TIMESTAMP(3),
PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
'connector' = 'upsert-kafka',
'topic' = 'order-upsert',
'properties.bootstrap.servers' = 'kafka:9092',
'key.format' = 'json',
'value.format' = 'json'
);
例子:
订单O001:100元
随后修正为:120元
如果消息Key不是稳定的order_id,而是每次随机eventId:
=> 下游可能得到两笔订单,而不是一次更新
避坑结论:
1)Upsert语义依赖稳定Key。
2)删除/tombstone、空值格式、Topic Compaction与保留策略要匹配。
3)下游不支持更新/删除时,不能机械把Upsert流当Append流处理。
4、CDC全量快照很慢,切换到增量时Binlog已被清理
MySQL CDC SQL示例:
sql
CREATE TABLE orders_cdc (
order_id STRING,
user_id STRING,
amount BIGINT,
update_time TIMESTAMP(3),
PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
'connector' = 'mysql-cdc',
'hostname' = 'mysql',
'port' = '3306',
'username' = 'flink',
'password' = '***',
'database-name' = 'demo',
'table-name' = 'orders'
);
事故:
大表全量快照需要12小时
源库Binlog只保留6小时
全量尚未完成/刚切换增量,所需位点已不存在
=> 无法续读,可能只能重新快照
上线检查:
1)Binlog保留时间覆盖最长快照与故障恢复窗口;
2)大表使用适合版本的增量快照/分片能力,并验证分片恢复;
3)检查server-id唯一性、权限、Row Image、GTID与主从切换;
4)低流量库配置心跳与位点推进监控;
5)快照期间源库IO、连接数和事务压力可承受。
5、CDC Schema Evolution:Source能读,Sink却写不进去
例子:
sql
-- 源库执行
ALTER TABLE orders MODIFY amount DECIMAL(20, 4);
下游可能仍按`BIGINT`或旧精度处理:
类型映射改变;
1)Avro/JSON Schema不兼容;
2)JDBC目标列精度不足;
3)历史Savepoint中状态类型不兼容。
Flink SQL显式结果表示例:
sql
CREATE TABLE order_result (
order_id STRING,
amount DECIMAL(20, 4),
PRIMARY KEY (order_id) NOT ENFORCED
) WITH (...);
避坑结论:
DDL变更要进入发布流程:Source类型映射 → 计算状态 → Savepoint → Sink Schema → 下游查询,逐段验证。
6、CDC用错时间字段,全量历史数据进入实时窗口后全部迟到
错误SQL思路:
sql
-- update_time是历史订单最后修改时间,全量快照中可能都是多年前数据
WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND
例子:
今天启动CDC,全量读到2020年历史订单
实时增量Watermark已推进到当前时间
若直接用历史update_time开窗,历史数据可能大规模迟到
参考设计:
1)全量初始化与实时增量分开计算;
2)实时窗口使用明确的业务事件时间,而非数据库接入时间;
3)需要"当前最新表"时按主键Upsert,不强行用事件时间窗口;
4)全量/增量衔接处单独设计Watermark和对账。
7、文件Sink目录里有文件,就认为数据完整可见
FileSink骨架示例:
java
FileSink<String> sink = FileSink
.forRowFormat(
new Path("hdfs:///warehouse/order_result"),
new SimpleStringEncoder<String>("UTF-8")
)
.withRollingPolicy(
DefaultRollingPolicy.builder()
.withRolloverInterval(Duration.ofMinutes(15))
.withInactivityInterval(Duration.ofMinutes(5))
.withMaxPartSize(128 * 1024 * 1024)
.build()
)
.build();
result.sinkTo(sink);
必查问题:
1)Checkpoint未完成时pending/in-progress文件如何处理;
2)作业取消与Savepoint恢复后文件是否重复/丢失;
3)对象存储是否支持目标提交协议所需语义;
4)小文件是否过多;
5)下游表分区元数据是否已提交;
6)压缩格式与恢复是否匹配。
避坑结论:
"目录有文件"不是完整性证明。要把成功Checkpoint、文件可见性和下游元数据一起对账。
八、Flink SQL:Changelog、Join、去重与状态
1、Append、Retract、Upsert混淆,下游结果越算越错
SQL示例:分组聚合天然可能产生更新
sql
SELECT region, SUM(amount) AS total_amount
FROM orders
GROUP BY region;
这不是简单Append:某个region的新订单到来后,旧汇总值需要被更新或撤回。
Changelog例子:
第一次:华东总额100
新订单+50后:
Retract视角:撤回华东/100,插入华东/150
Upsert视角:按region主键更新为150
Append-only下游:可能只看到多行结果,无法正确覆盖
正确Sink设计:
sql
CREATE TABLE region_total (
region STRING,
total_amount BIGINT,
PRIMARY KEY (region) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:mysql://mysql:3306/demo',
'table-name' = 'region_total'
);
同时确保MySQL目标表存在真实主键/唯一键
2、双流Regular Join状态无限增长
SQL示例:
sql
SELECT o.order_id, o.amount, p.pay_time
FROM orders o
JOIN payments p ON o.order_id = p.order_id;
根因:
一侧数据到来后,另一侧可能在未来任意时间到来,因此通常需要长期保存双方状态。
改成Interval Join示例:
sql
SELECT o.order_id, o.amount, p.pay_time
FROM orders o
JOIN payments p
ON o.order_id = p.order_id
AND p.pay_time BETWEEN o.order_time - INTERVAL '5' MINUTE
AND o.order_time + INTERVAL '30' MINUTE;
具体区别:
Regular Join:订单今天到,支付一年后来,理论上仍可能匹配
Interval Join:只保留订单前后指定时间范围,边界外可清理
避坑结论:
不能只为了状态小而随意缩窄区间;必须覆盖真实最大支付延迟与迟到范围。
3、用`table.exec.state.ttl`解决所有SQL状态问题
配置示例:
java
TableEnvironment tableEnv = TableEnvironment.create(EnvironmentSettings.inStreamingMode());
tableEnv.getConfig().set("table.exec.state.ttl", "24 h");
具体风险:
订单O001周一到
支付O001周三到
Join一侧状态24小时后过期
=> 周三无法匹配,但SQL不会报"业务漏数"异常
避坑结论:
SQL状态TTL是全局资源策略,可能改变Join、去重、聚合等结果。配置前必须证明业务允许遗忘。
4、窗口聚合结果写Append Sink,迟到修正变成重复行
SQL示例:
sql
SELECT
region,
TUMBLE_START(event_time, INTERVAL '5' MINUTE) AS window_start,
SUM(amount) AS total_amount
FROM orders
GROUP BY region, TUMBLE(event_time, INTERVAL '5' MINUTE);
例子:
窗口首次输出:华东,10:00,100
迟到修正后:华东,10:00,150
正确下游主键:
sql
PRIMARY KEY (region, window_start) NOT ENFORCED
目标数据库也应对应建立唯一键,保证修正结果覆盖同一窗口,而不是追加一行。
5、SQL去重与TopN不理解更新语义
按order_id保留最新记录:
sql
SELECT order_id, amount, update_time
FROM (
SELECT o.*,
ROW_NUMBER() OVER (
PARTITION BY order_id ORDER BY update_time DESC
) AS rn
FROM orders o
) t
WHERE rn = 1;
避坑点:
1)去重依赖状态记忆,TTL过短会失效;
2)`update_time`相同需增加稳定tie-breaker,否则结果不确定;
3)TopN排名变化会输出更新/撤回;
4)下游需要按order_id支持更新/删除;
5)热点order_id、超宽行和大量版本会增加状态成本。
6、Lookup Join查到最新维值,却不是事件发生时维值
SQL示例:
sql
SELECT o.order_id, o.amount, d.region_name
FROM orders o
LEFT JOIN dim_region FOR SYSTEM_TIME AS OF o.proc_time AS d
ON o.region = d.region_id;
例子:
订单10:00发生时,region=A名称为"华东一区"
11:00维表改名为"华东核心区"
12:00补算订单,Lookup查到的是"华东核心区"
如果业务要求订单发生时名称,这种结果就不正确。
解决方向:
1)使用带版本和事件时间的Temporal Join;
2)保存维表历史版本;
3)或明确业务接受处理时最新维值,并监控缓存新鲜度。
7、修改一句SQL,执行计划和状态布局同时变化
原SQL:
sql
SELECT region, SUM(amount) FROM orders GROUP BY region;
新SQL:
sql
SELECT region, SUM(amount) FROM (
SELECT region, amount FROM orders WHERE amount > 0
) GROUP BY region;
业务看似等价,但Planner可能改变过滤位置、节点拓扑、交换和Changelog处理。
发布前必须做:
EXPLAIN新旧SQL
=> 对比执行计划
=> 验证Savepoint恢复
=> 同一输入双跑对账
=> 检查Sink是否仍支持新的Changelog类型
九、CEP、广播状态与自定义Function
1、CEP不限制匹配时间,部分匹配状态无限积累
风险示例:
java
Pattern<OrderEvent, ?> pattern = Pattern.<OrderEvent>begin("create")
.next("pay")
// 没有within,长时间未支付的create可能长期占用NFA状态
;
推荐:明确业务匹配窗口
java
Pattern<OrderEvent, ?> pattern = Pattern.<OrderEvent>begin("create")
.where(new SimpleCondition<OrderEvent>() {
@Override public boolean filter(OrderEvent e) { return "CREATE".equals(e.eventId); }
})
.next("pay")
.where(new SimpleCondition<OrderEvent>() {
@Override public boolean filter(OrderEvent e) { return "PAY".equals(e.eventId); }
})
.within(Time.hours(2));
实际业务应使用独立eventType字段,以上只为突出Pattern结构。
避坑点:
1)`within`应覆盖业务最长合法匹配跨度;
2)乱序和迟到会改变匹配;
3)宽松量词可能产生组合爆炸;
4)AfterMatchSkipStrategy会影响重叠匹配与输出数量;
5)超时未匹配应通过明确输出/告警处理,而非默默丢弃。
2、广播规则流与主流顺序不确定,结果不一致
广播状态代码:
java
MapStateDescriptor<String, String> ruleState =
new MapStateDescriptor<>("rules", String.class, String.class);
BroadcastStream<String> rules = ruleStream.broadcast(ruleState);
DataStream<String> output = orders
.keyBy(e -> e.region)
.connect(rules)
.process(new KeyedBroadcastProcessFunction<String, OrderEvent, String, String>() {
@Override
public void processElement(OrderEvent e, ReadOnlyContext ctx, Collector<String> out)
throws Exception {
String rule = ctx.getBroadcastState(ruleState).get(e.region);
out.collect(e.orderId + ":" + rule);
}
@Override
public void processBroadcastElement(String ruleJson, Context ctx, Collector<String> out)
throws Exception {
// 解析为region/rule后更新广播状态,示例省略解析
Rule r = parse(ruleJson);
ctx.getBroadcastState(ruleState).put(r.region, r.expression);
}
@Override public void onTimer(long ts, ReadOnlyContext ctx, Collector<String> out) {}
});
例子:
主流订单与规则更新几乎同时到达
不同并行任务应用规则的时点可能不同
不能假设广播规则对所有事件有一个天然全局生效瞬间
避坑结论:
1)广播状态适合小规则,不适合无限大维表;
2)规则需要版本、生效时间和确定性;
3)若要求严格按生效时间匹配,应把规则版本与事件时间纳入处理逻辑。
3、每个Key重复注册Timer,状态和触发量失控
危险代码:
java
@Override
public void processElement(OrderEvent e, Context ctx, Collector<String> out) {
// 每条事件都注册不同Timer,同一Key可能累积大量Timer
ctx.timerService().registerEventTimeTimer(e.eventTime + 3600_000);
}
推荐:只保存并注册必要Timer
java
private transient ValueState<Long> nextTimer;
@Override
public void open(Configuration p) {
nextTimer = getRuntimeContext().getState(
new ValueStateDescriptor<>("next-timer", Long.class)
);
}
@Override
public void processElement(OrderEvent e, Context ctx, Collector<String> out) throws Exception {
long candidate = e.eventTime + 3600_000;
Long old = nextTimer.value();
if (old == null || candidate < old) {
// 实际生产还应按语义清理旧Timer,避免旧触发残留
ctx.timerService().registerEventTimeTimer(candidate);
nextTimer.update(candidate);
}
}
避坑结论:
State与Timer要成对设计生命周期:谁创建、谁更新、谁取消、谁在`onTimer`后清理。
十、序列化、闭包、依赖与生命周期
1、POJO规则被破坏,性能与状态兼容性一起退化
不良示例:
java
public class BadState {
private String value; // 无Getter/Setter,字段又非public
public BadState(String v) { // 没有公有无参构造
this.value = v;
}
}
推荐示例:
java
public class GoodState {
public String value;
public long count;
public GoodState() {}
}
避坑结论:
类型退化到通用序列化后,不只是一点点性能问题,还可能影响状态Schema演进与恢复。关键状态要显式做兼容测试。
2、Lambda捕获不可序列化对象,本地编译通过集群失败
错误代码:
java
public class OrderJob {
private final HttpClient client = HttpClient.newHttpClient();
public void build(DataStream<OrderEvent> stream) {
stream.map(e -> {
client.sendAsync(null, null); // 捕获外层不可序列化对象/错误生命周期
return e;
});
}
}
推荐:函数对象只传配置,连接在open创建
java
public class HttpEnrich extends RichMapFunction<OrderEvent, OrderEvent> {
private final String endpoint; // 可序列化配置
private transient HttpClient client; // TM端资源,不随闭包序列化
public HttpEnrich(String endpoint) {
this.endpoint = endpoint;
}
@Override public void open(Configuration p) {
client = HttpClient.newHttpClient();
}
@Override public OrderEvent map(OrderEvent e) {
// 同步HTTP会阻塞且可能反压;生产更常用Async I/O或合适Sink
return e;
}
}
3、在`open`中做不可重复副作用,重启后重复执行
危险代码:
java
@Override
public void open(Configuration p) {
jdbc.execute("INSERT INTO job_audit VALUES ('started')");
}
Task失败恢复、并行实例初始化都会执行`open`,可能写入多条。
正确理解:
1)`open`:初始化连接、线程安全的客户端、Metric、状态句柄;
2)`close`:释放资源;
3)不应把"整个Job只执行一次"的业务动作放在每个Task的`open`;
4)需要Job级初始化应使用部署流程或具备分布式幂等/选主语义的机制。
4、用户Jar打入过多Flink依赖,出现奇怪类冲突
Maven示例:
XML
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-streaming-java</artifactId>
<version>${flink.version}</version>
<scope>provided</scope>
</dependency>
常见冲突对象:
1)Flink Runtime与Scala版本;
2)Kafka Client;
3)Hadoop;
4)Netty、Jackson、Guava;
5)日志框架;
6)多个Connector的传递依赖
避坑结论:
Session集群多作业共享环境时隔离更弱。不要只验证"单作业能跑",还要验证目标部署模式和完整依赖树。
十一、常见报错:代码、根因与处置速查
1、`Checkpoint expired before completing`
不应只做:
java
cp.setCheckpointTimeout(3600_000);
应该结合定位:
Checkpoint历史找最慢subtask
-> 若个别慢:热Key/状态大/本地盘
-> 若普遍慢:Sink反压/存储慢/资源不足
-> 若对齐慢:多路输入与反压
-> 若上传慢:远端存储、带宽、大文件
2、`ProducerFencedException`
危险配置:
java
// 两个独立作业错误共用同一前缀
.setTransactionalIdPrefix("shared-prefix");
推荐原则:
java
// 同一逻辑作业稳定,不同逻辑作业隔离
.setTransactionalIdPrefix("order-agg-prod-01");
同时确认旧实例已经停止,避免zombie producer。不要为了绕开fencing每次启动随机换前缀,这可能破坏事务恢复管理。
3、`Direct buffer memory`或容器OOMKilled
错误处置:
bash
# 只加堆,可能进一步挤压容器内Direct/Native余量
JAVA_TOOL_OPTIONS=-Xmx7g
正确处置:
1)分清OOM文本、JVM Heap与容器OOMKilled;
2)检查Network、Direct、RocksDB、Native、Slot数;
3)保留NMT/GC/容器指标;
4)降低无界队列、大批量和用户Native客户端内存。
4、`Heartbeat Timeout`/RPC Timeout
不应只做:
XML
# 全部超时乘十,可能掩盖长GC和宿主机故障
heartbeat.timeout: 600000
应保留证据:
bash
# 故障附近采集:GC日志、线程栈、容器CPU限流、宿主机负载、DNS/网络
jstack <pid>
长GC、CPU限流、宿主机负载、DNS、JM压力都可能表现为Heartbeat问题。
5、SQL Sink报不支持更新/撤回
典型SQL:
sql
INSERT INTO append_only_sink
SELECT region, SUM(amount) FROM orders GROUP BY region;
根因:
分组聚合结果会更新,Append-only Sink无法表达撤回/更新。
解决:
1)改用支持Upsert的Sink,并定义主键;
2)或改成真正Append的窗口最终结果模型,但仍要考虑迟到修正;
3)不要简单把更新标志过滤掉,那会直接造成结果错误。
十二、烧脑问题
1、为什么Checkpoint成功,业务仍可能重复?
订单事件E001
-> Flink状态更新成功
-> map中调用支付接口成功
-> Checkpoint尚未完成,Task失败
-> 恢复后E001再处理
-> 支付接口再执行
Flink无法自动回滚任意外部HTTP副作用,必须幂等或事务化
2、为什么Watermark到10:10,还能收到10:00的事件?
Watermark是系统根据策略发出的进度估计
上游补录、网络延迟、CDC重放都可能带来更早事件
迟到策略是必需设计,不是异常补丁
3、为什么状态TTL让结果错了却不报警?
Join左流订单周一到,状态24小时过期
右流支付周三到,查不到左侧状态
=> 没有输出,但作业完全健康
正确性监控要包含Join匹配率、对账和长时间无输出,而不只是RUNNING。
4、为什么Savepoint恢复成功仍可能少状态?
旧算子自动ID=A,新代码拓扑变化后自动ID=B
状态文件里有A,但新作业找B
=> 可能无法匹配旧状态
UID、状态Schema、执行计划和业务对账缺一不可。
5、为什么Unaligned Checkpoint完成后反压仍存在?
Sink每秒只能写1000条
Source每秒输入10000条
快照方式改变,不会把Sink变成10000条/秒
流控问题必须处理实际瓶颈
6、为什么`keyBy(eventId)`去重后,还要重新`keyBy(region)`?
去重状态需要按eventId作用域
聚合状态需要按region作用域
Keyed State的Key是状态语义的一部分,不只是网络分区手段
7、为什么Kafka事务Sink还要求下游`read_committed`?
事务可能最终中止
未提交消息物理上曾存在于日志
下游隔离级别决定它能否避免读取最终回滚的数据
8、为什么Lookup Join越"新"反而越错?
业务需要订单发生时用户等级
Lookup返回补算时最新等级
处理时最新值与事件时版本值是两种业务语义
十三、综合问题:订单去重、窗口聚合、迟到修正与幂等Sink
1、需求与一致性口径
输入:Kafka订单事件,可能乱序、重复、迟到
处理:
1.按eventId去重
2.按region做5分钟Event Time聚合
3.允许10分钟迟到修正,更晚事件进侧输出
输出:MySQL按region + window_start覆盖最终结果
故障:允许从Checkpoint/Savepoint恢复,不能因重试重复累加
2、代码骨架
java
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000, CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().enableExternalizedCheckpoints(
CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
);
DataStream<OrderEvent> source = env.fromSource(
kafkaSource,
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofMinutes(2))
.withTimestampAssigner((e, ts) -> e.eventTime)
.withIdleness(Duration.ofMinutes(1)),
"orders-source"
).uid("orders-source-v1");
// 1. 去重Key必须是eventId
DataStream<OrderEvent> distinct = source
.keyBy(e -> e.eventId)
.process(new DeduplicateFunction())
.uid("event-deduplicate-v1");
// 2. 聚合Key改为region
OutputTag<OrderEvent> extremeLate = new OutputTag<>("extreme-late"){};
SingleOutputStreamOperator<WindowResult> aggregated = distinct
.keyBy(e -> e.region)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(10))
.sideOutputLateData(extremeLate)
.aggregate(new SumAgg(), new WindowResultFunction())
.uid("region-five-minute-agg-v1");
// 3. 主结果按窗口主键幂等覆盖
aggregated.addSink(windowResultJdbcUpsertSink)
.uid("window-result-jdbc-v1");
// 4. 极端迟到进入补偿链路,不能静默丢弃
aggregated.getSideOutput(extremeLate)
.map(e -> "late compensation: " + e.eventId)
.addSink(lateAuditSink)
.uid("late-audit-sink-v1");
`WindowResult`至少应包含:
java
public class WindowResult {
public String region;
public long windowStart;
public long amount;
public WindowResult() {}
}
MySQL目标表:
sql
CREATE TABLE region_window_result (
region VARCHAR(64) NOT NULL,
window_start BIGINT NOT NULL,
amount BIGINT NOT NULL,
PRIMARY KEY(region, window_start)
);
写入SQL:
sql
INSERT INTO region_window_result(region, window_start, amount)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE amount = VALUES(amount);
3、代码进一步验证
1)`DeduplicateFunction`的TTL 必须覆盖业务最大重放跨度;永久去重不能靠无限状态硬撑,需要外部幂等存储或冷热分层。
2)`SumAgg`示例作用于OrderEvent,综合案例中应实现WindowResult对应聚合输出。
3)JDBC Upsert解决的是目标表幂等覆盖,不等于所有多表外部副作用自动Exactly-Once。
4)迟到修正会多次更新同一窗口结果,必须确认业务读取口径接受最新值。
5)作业升级要验证UID、状态Schema和Savepoint。
6)压测必须包含热region、整点触发、MySQL降速和TaskManager故障。
十四、上线与故障演练检查
1、时间与窗口
1)Event Time字段、时区、单位与异常时间戳策略明确。
2)Watermark最大乱序有真实数据依据,不凭经验拍脑袋。
3)空闲Source、多路输入、低峰期Watermark推进经过测试。
4)迟到修正和极端迟到补偿均有下游处理方案。
5)下游能处理窗口多次更新,而非误当重复行。
2、状态与恢复
1)有状态算子UID稳定、唯一。
2)去重Key、Join Key、聚合Key与状态作用域一致。
3)TTL覆盖业务合法重放/关联跨度。
4)状态增长量、最大并行度和恢复时间有容量模型。
5)POJO/Serializer/状态Descriptor变更有兼容测试。
6)Savepoint恢复后做业务对账,不只看RUNNING。
3、Checkpoint与Sink
1)Source-状态-Sink-下游可见性全链路一致性验证。
2)外部HTTP、DB、Redis等副作用幂等或事务化。
3)Kafka下游隔离级别、事务超时、事务ID前缀正确。
4)JDBC目标表存在真实唯一键,重试不会重复插入。
5)文件Sink的pending文件、可见性和小文件经过故障测试。
4、性能与资源
1)压测包含热 Key、窗口整点、维表慢查询和Sink降速。
2)按subtask看busy/idle/backpressured,不只看Job平均值。
3)Source分区/分片、并行度和Sink外部容量匹配。
4)容器总内存覆盖Heap、Direct、Network、Native、RocksDB与余量。
5)异步IO有界,超时、重试、连接池和外部QPS匹配。
5、故障演练
1)Kill TaskManager:恢复时间、重复写、对账。
2)Kill JobManager:HA与事务恢复。
3)Source停止10分钟:Watermark、Timer、恢复追数。
4)Sink降速:反压、Checkpoint、重试与外部事务。
5)注入热Key、重复事件、未来时间戳、超迟到事件。
6)修改并行度、SQL、POJO后验证Savepoint与结果。