1. 从一条广告点击日志说起:这个统计任务到底在算什么
广告点击量统计这件事,听起来像是数据平台里最基础的一环,但真正落到实时计算场景里,坑远比想象中多。我最早接触这个需求的时候,第一反应是“不就是按广告位分组计数吗”,结果上线第一天就被脏数据教做人——某个广告位在十分钟内被同一个设备点了两万多次,下游报表直接爆表。后来才明白,点击量统计的核心难点从来不是计数本身,而是如何在数据洪流里把无效点击、恶意点击、重复点击干净利落地剔除掉。
这篇内容要聊的,就是基于 Flink 做页面广告点击量统计,并且把黑名单过滤这条链路完整地串起来。所谓黑名单过滤,指的是在统计之前,先把那些已知的恶意用户、异常设备、刷量 IP 从数据流里筛掉,只让“干净”的点击进入计数逻辑。它解决的是数据质量问题,适合已经对 Flink 有基本了解、正在做实时指标统计、并且被脏数据困扰的开发者参考。如果你还在用批处理跑 T+1 的报表,或者实时任务里完全没有过滤逻辑,那这套思路值得你花时间看完。
我先把整体链路用一句话说清楚:广告点击事件从消息队列进来,先经过黑名单过滤算子,再按广告位维度做窗口聚合,最后把统计结果写到下游存储。听起来简单,但每一步都有讲究。比如黑名单数据从哪来、怎么动态更新、过滤算子放在哪个位置、窗口用滚动还是滑动、状态怎么管理、迟到数据怎么处理,这些细节决定了你的任务能不能稳定跑下去。
这里有个很多人一开始会忽略的点:黑名单过滤和点击量统计,本质上是一对矛盾体。过滤要求尽可能严格,宁可错杀不可放过;统计又要求尽可能完整,不能漏掉真实点击。怎么平衡?我的经验是分层处理——强黑名单直接丢弃,弱黑名单打标但不丢弃,让下游根据业务需要决定是否计入。这个思路后面会详细展开。
另外要说明的是,本文涉及的所有代码和配置都是基于常见实践整理的示例,实际落地时需要根据你的数据规模、集群资源和业务规则做调整。我不会给你一套“复制就能跑”的万能模板,因为实时计算这行,脱离具体场景谈方案就是耍流氓。但我会把每个决策背后的逻辑讲透,让你知道为什么这么选,以及不这么选会踩什么坑。
2. 黑名单过滤到底该放在哪一层:位置选择的三种方案对比
2.1 为什么过滤位置是个需要认真对待的问题
很多人拿到需求,第一反应是“在数据源后面加个 filter 算子不就完了”。这个想法没错,但太粗糙。过滤位置的选择,直接影响三件事:状态大小、计算开销、以及黑名单更新的实时性。我见过有团队把黑名单过滤放在窗口聚合之后,结果窗口里已经算进了脏数据,再过滤只能把整个窗口结果丢掉,等于白算。也见过把黑名单加载到每个算子里做本地缓存的,黑名单一更新就得重启任务,运维成本极高。
所以过滤位置不是随便放的,它取决于你的黑名单规模、更新频率、以及你对延迟的容忍度。下面我把常见的三种方案拆开讲,每种都给出适用场景和实测表现。
2.2 方案一:数据源接入后立即过滤
这是最直观的做法,在 Source 之后直接接一个ProcessFunction或者RichFilterFunction,每条数据进来先查黑名单,命中就丢弃,不命中就往下游发。
DataStream<AdClickEvent> filteredStream = sourceStream .filter(new RichFilterFunction<AdClickEvent>() { private transient ValueState<Boolean> blacklistState; @Override public void open(Configuration parameters) { // 初始化状态描述符,用于存储黑名单标记 ValueStateDescriptor<Boolean> descriptor = new ValueStateDescriptor<>("blacklist", Boolean.class); blacklistState = getRuntimeContext().getState(descriptor); } @Override public boolean filter(AdClickEvent event) throws Exception { // 查询外部黑名单或本地缓存 boolean isBlacklisted = BlacklistCache.contains(event.getUserId()); return !isBlacklisted; } });这个方案的好处是脏数据最早被拦截,下游所有算子都不用为无效数据付出计算成本。如果你的黑名单规模不大(比如几万到几十万条),完全可以把它加载到每个 TaskManager 的堆内缓存里,查询走本地内存,延迟在微秒级。
但问题也很明显:黑名单更新不及时。本地缓存意味着每个并行子任务都持有一份副本,黑名单变更后需要广播到所有节点,Flink 里可以用广播状态(Broadcast State)来实现,但广播本身有延迟,而且黑名单越大,广播开销越高。我实测过一个百万级黑名单用广播状态更新,每次全量广播要几秒钟,期间新数据可能漏过过滤。
提示:如果黑名单更新频率低于每分钟一次,且规模在十万级以内,这个方案完全够用。但如果黑名单是实时变化的(比如风控系统每秒都在产出新的恶意用户),就得考虑下面的方案。
2.3 方案二:旁路查询外部存储
把黑名单放在外部存储(比如 Redis、HBase),过滤算子每条数据都去查一次。这样黑名单更新是实时的,风控系统写入后立刻生效。
public class BlacklistQueryFunction extends RichFlatMapFunction<AdClickEvent, AdClickEvent> { private transient Jedis jedis; @Override public void open(Configuration parameters) { // 初始化连接池,注意每个并行子任务独立连接 jedis = new Jedis("blacklist-host", 6379); } @Override public void flatMap(AdClickEvent event, Collector<AdClickEvent> out) { // 用 userId 作为 key 查询黑名单 Boolean exists = jedis.sismember("ad:blacklist:users", event.getUserId()); if (exists == null || !exists) { out.collect(event); } // 命中黑名单的直接丢弃,不往下游发 } }这个方案的优点是黑名单实时性最好,且不受本地内存限制,理论上可以支撑千万级甚至更大的黑名单。但代价是每条数据都要走一次网络 IO,如果 QPS 很高,Redis 很容易成为瓶颈。我压测过一个场景,单 Redis 实例在 5 万 QPS 下响应时间开始明显抖动,超过 8 万 QPS 就出现超时。
优化思路有两个:一是加本地缓存做多级查询,热点黑名单放本地,冷门数据走 Redis;二是用异步 IO(Flink 的AsyncDataStream)来避免同步等待阻塞算子线程。异步 IO 的写法这里不展开,核心是把查询请求批量发出去,用回调处理结果,能显著提升吞吐。
2.4 方案三:广播状态动态更新
这是 Flink 官方推荐的动态规则匹配方案。把黑名单作为广播流,主数据流和广播流连接(connect),在KeyedBroadcastProcessFunction里用广播状态存储黑名单,每条数据进来查状态即可。
// 黑名单广播流 BroadcastStream<String> blacklistBroadcast = blacklistStream .broadcast(blacklistStateDescriptor); // 主数据流连接广播流 DataStream<AdClickEvent> filtered = clickStream .connect(blacklistBroadcast) .process(new BlacklistFilterFunction()); // 过滤函数核心逻辑 public class BlacklistFilterFunction extends KeyedBroadcastProcessFunction<String, AdClickEvent, String, AdClickEvent> { @Override public void processElement(AdClickEvent event, ReadOnlyContext ctx, Collector<AdClickEvent> out) throws Exception { // 从广播状态读取黑名单 ReadOnlyBroadcastState<String, Boolean> state = ctx.getBroadcastState(blacklistStateDescriptor); if (state.get(event.getUserId()) == null) { out.collect(event); } } @Override public void processBroadcastElement(String userId, Context ctx, Collector<AdClickEvent> out) throws Exception { // 更新广播状态 BroadcastState<String, Boolean> state = ctx.getBroadcastState(blacklistStateDescriptor); state.put(userId, true); } }这个方案兼顾了实时性和性能:黑名单更新通过广播流实时同步,查询走本地状态,没有网络开销。但要注意,广播状态是存在每个并行子任务的内存里的,黑名单太大会撑爆内存。我的经验是广播状态适合百万级以内的黑名单,再大就得考虑分片或者换方案。
三种方案的对比我整理成表格,方便你按场景选:
| 方案 | 黑名单规模 | 更新实时性 | 查询延迟 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 本地缓存过滤 | 十万级以内 | 分钟级 | 微秒级 | 低 | 黑名单稳定、QPS 高 |
| 旁路查询外部存储 | 千万级 | 秒级 | 毫秒级 | 中 | 黑名单频繁更新 |
| 广播状态 | 百万级以内 | 秒级 | 微秒级 | 中高 | 实时性与性能兼顾 |
2.5 我的实际选择与理由
在最近一个广告点击统计项目里,我最终用的是广播状态 + 本地缓存兜底的混合方案。具体来说,风控系统产出的黑名单通过广播流实时同步到过滤算子,同时算子内部维护一个 LRU 本地缓存,缓存最近命中的黑名单用户。这样即使广播有短暂延迟,本地缓存也能挡住大部分重复的恶意点击。
为什么不选旁路查询?因为那个项目的点击 QPS 峰值到了 12 万,Redis 扛不住。为什么不选纯本地缓存?因为风控要求黑名单变更后 5 秒内生效,本地缓存的广播延迟满足不了。混合方案是在性能和实时性之间找到的平衡点,实测下来过滤延迟稳定在 2 秒以内,CPU 开销比纯旁路查询低了 60%。
3. 点击量统计的窗口设计:滚动、滑动还是会话窗口
3.1 窗口类型的选择逻辑
过滤完之后,干净的数据要进入窗口做聚合。Flink 提供了三种常用窗口:滚动窗口(Tumbling)、滑动窗口(Sliding)、会话窗口(Session)。选哪种,取决于你的报表需求。
滚动窗口是固定长度、不重叠的窗口,比如每 5 分钟统计一次点击量。它的优点是实现简单、状态可控,每个事件只属于一个窗口。缺点是统计结果是“跳变”的,5 分钟整点才输出一次,中间没有更新。
滑动窗口是固定长度、有重叠的窗口,比如窗口长度 10 分钟、滑动步长 1 分钟。它每 1 分钟输出一次过去 10 分钟的统计结果,曲线更平滑。但代价是每个事件会属于多个窗口,状态和计算量成倍增加。我实测过,滑动步长是窗口长度的 1/10 时,状态大小大约是滚动窗口的 10 倍。
会话窗口是按活动间隙切分的,比如用户停止点击 30 秒后认为会话结束。它适合分析用户行为序列,但不太适合广告点击量这种固定周期的统计需求。
我的建议很直接:广告点击量统计优先用滚动窗口。因为报表通常就是按固定周期出数的,滚动窗口的状态管理最简单,出问题时排查也容易。如果业务方要求“实时曲线”,可以用滑动窗口,但一定要控制好步长和窗口长度的比例,别搞出 1 秒步长、1 小时窗口这种配置,状态会爆炸。
3.2 窗口聚合的代码实现与增量计算
滚动窗口的聚合代码大概长这样:
DataStream<AdClickCount> windowedCount = filteredStream .keyBy(AdClickEvent::getAdSlotId) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .aggregate(new ClickCountAggregator(), new ClickCountWindowFunction());这里有个关键点:用aggregate而不是apply。aggregate是增量聚合,每来一条数据就更新累加器,窗口触发时只输出最终结果,状态里只存累加器(一个 long 值)。apply是全量聚合,窗口触发时把所有数据拉出来遍历,状态里存的是所有原始事件。两者在状态大小和性能上差距巨大,数据量大时apply基本不可用。
// 增量聚合函数:每来一条数据就累加 public class ClickCountAggregator implements AggregateFunction<AdClickEvent, Long, Long> { @Override public Long createAccumulator() { return 0L; } @Override public Long add(AdClickEvent event, Long accumulator) { return accumulator + 1; } @Override public Long getResult(Long accumulator) { return accumulator; } @Override public Long merge(Long a, Long b) { return a + b; } }这个累加器只占 8 字节,即使有几十万个广告位,状态也就几 MB。如果用apply,每个窗口要缓存所有事件,内存占用是数量级的差距。
3.3 事件时间与水位线的处理
广告点击统计必须用事件时间,不能用处理时间。因为数据从产生到进入 Flink 有延迟,用处理时间会导致统计结果和实际发生时间对不上。事件时间需要配合水位线(Watermark)使用,水位线告诉 Flink “某个时间点之前的数据都到齐了”。
DataStream<AdClickEvent> withWatermark = stream .assignTimestampsAndWatermarks( WatermarkStrategy.<AdClickEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5)) .withTimestampAssigner((event, timestamp) -> event.getClickTime()) );forBoundedOutOfOrderness表示允许数据最多迟到 5 秒。这个值怎么定?我的经验是看数据源的乱序程度。如果消息队列本身有序,1 秒就够;如果经过多个中间环节,可能需要 10 秒甚至更多。设太小会丢迟到数据,设太大会增加窗口触发延迟。
注意:水位线是全局推进的,如果某个并行子任务没有数据,它的水位线不会前进,会拖慢整个任务。解决办法是设置
withIdleness,让空闲子任务的水位线自动推进。
3.4 迟到数据的三种处理策略
即使设了水位线,还是会有迟到数据。Flink 提供了三种处理方式:
- 默认丢弃:窗口触发后到达的数据直接扔掉。适合对完整性要求不高的场景。
- allowedLateness:允许窗口触发后再保留一段时间,迟到数据还能触发更新。比如设 1 分钟,窗口触发后 1 分钟内的迟到数据会重新计算并输出更新结果。
- sideOutput:把迟到数据输出到侧流,单独处理。适合需要记录迟到数据做分析的场景。
OutputTag<AdClickEvent> lateTag = new OutputTag<AdClickEvent>("late-data"){}; SingleOutputStreamOperator<AdClickCount> result = filteredStream .keyBy(AdClickEvent::getAdSlotId) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .allowedLateness(Time.minutes(1)) .sideOutputLateData(lateTag) .aggregate(new ClickCountAggregator(), new ClickCountWindowFunction()); DataStream<AdClickEvent> lateStream = result.getSideOutput(lateTag);我一般会同时开allowedLateness和sideOutput:前者保证统计结果尽量准确,后者用来监控迟到数据的量。如果侧流数据量突然增大,说明数据源出了问题,需要排查。
4. 状态管理与容错:让任务能稳定跑上几个月
4.1 状态后端的选择
Flink 的状态后端决定了状态存在哪、怎么存。常见的有三种:HashMapStateBackend(内存)、EmbeddedRocksDBStateBackend(RocksDB)、以及基于文件系统的状态后端。
对于广告点击统计这种场景,状态大小通常在几百 MB 到几 GB 之间。如果状态能放进内存,用HashMapStateBackend性能最好,读写都是内存操作。但如果状态可能超过内存,就得用RocksDBStateBackend,它把状态存在本地磁盘,支持超出内存的状态量,代价是读写有磁盘 IO 开销。
// 配置 RocksDB 状态后端 Configuration config = new Configuration(); config.setString("state.backend", "rocksdb"); config.setString("state.backend.rocksdb.localdir", "/data/flink/rocksdb"); config.setString("state.checkpoints.dir", "hdfs:///flink/checkpoints");我的经验是:如果广告位数量在十万级以内,用内存状态后端;超过十万,或者窗口状态很大,用 RocksDB。另外 RocksDB 的本地目录一定要配到 SSD 上,机械硬盘的随机读写会让性能下降一个数量级。
4.2 Checkpoint 配置与调优
Checkpoint 是 Flink 容错的核心机制,它定期把状态快照写到持久化存储。配置不当会导致 Checkpoint 超时、失败,甚至影响正常数据处理。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.enableCheckpointing(60000); // 每 60 秒一次 env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000); // 两次间隔至少 30 秒 env.getCheckpointConfig().setCheckpointTimeout(120000); // 超时 2 分钟 env.getCheckpointConfig().setMaxConcurrentCheckpoints(1); // 不并发 env.getCheckpointConfig().setTolerableCheckpointFailureNumber(3); // 容忍 3 次失败几个关键参数的解释:
- Checkpoint 间隔:太短会增加开销,太长会导致故障恢复时重放数据过多。我一般设 1 到 5 分钟,看数据量和恢复时间要求。
- 最小间隔:防止前一个 Checkpoint 还没完,后一个就开始了。设成间隔的一半左右比较稳妥。
- 超时时间:如果状态很大,写 HDFS 可能超过默认的 10 分钟,需要调大。
- 容忍失败次数:偶尔失败不用让整个任务挂掉,设个容忍值更稳。
提示:Checkpoint 失败最常见的原因是状态太大导致写入超时。如果频繁失败,先看状态大小,再考虑调大超时或优化状态结构。
4.3 重启策略与故障恢复
任务挂了怎么恢复?Flink 提供了多种重启策略:
// 固定延迟重启:最多重启 5 次,每次间隔 10 秒 env.setRestartStrategy(RestartStrategies.fixedDelayRestart( 5, Time.seconds(10) )); // 指数延迟重启:间隔逐渐增大,适合避免频繁重启打爆集群 env.setRestartStrategy(RestartStrategies.exponentialDelayRestart( Time.seconds(1), Time.minutes(5), 2.0, Time.hours(1), 10 ));我一般用指数延迟重启,因为如果任务是因为代码 bug 挂的,固定间隔重启会反复失败,指数延迟能给运维争取排查时间。另外要配合告警,任务重启次数超过阈值就通知人介入。
4.4 大状态下的性能优化
状态大了之后,性能问题会逐渐暴露。我踩过的几个坑:
第一个坑是状态序列化开销。Flink 默认用 Java 序列化,效率低。换成 Kryo 或者自定义序列化器能提升不少。对于简单的 POJO,实现Serializable接口并注册到 Kryo 就行。
第二个坑是 RocksDB 的写放大。RocksDB 的 LSM 树结构在写入量大时会有明显的写放大,导致磁盘 IO 飙升。解决办法是调大write_buffer_size和max_write_buffer_number,让更多数据在内存里合并后再落盘。
第三个坑是 Checkpoint 期间的背压。Checkpoint 时状态要快照,如果状态太大,快照期间数据处理会变慢,产生背压。可以开启增量 Checkpoint(RocksDB 支持),只上传变化的部分,大幅减少快照数据量。
// 开启 RocksDB 增量 Checkpoint env.getCheckpointConfig().enableExternalizedCheckpoints( CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION ); config.setString("state.backend.rocksdb.incremental", "true");5. 黑名单过滤与统计链路的联调实测
5.1 测试数据的构造
联调阶段最麻烦的是造数据。真实广告点击数据不好拿,我一般用脚本生成模拟数据,关键是要覆盖各种边界情况:
- 正常点击:随机用户、随机广告位、时间戳递增
- 黑名单用户点击:用已知的黑名单 ID 构造
- 重复点击:同一用户对同一广告位短时间多次点击
- 迟到数据:时间戳明显早于当前水位线的数据
- 乱序数据:时间戳不按顺序到达
import random import time import json def generate_click_event(): user_id = f"user_{random.randint(1, 100000)}" ad_slot_id = f"slot_{random.randint(1, 1000)}" click_time = int(time.time() * 1000) - random.randint(0, 10000) return json.dumps({ "userId": user_id, "adSlotId": ad_slot_id, "clickTime": click_time }) # 生成 10 万条测试数据 with open("test_clicks.json", "w") as f: for _ in range(100000): f.write(generate_click_event() + "\n")5.2 过滤效果的验证方法
怎么确认黑名单过滤真的生效了?我的做法是在过滤算子前后各埋一个计数器,统计进入和流出的数据量,差值就是被过滤掉的数量。
public class BlacklistFilterFunction extends RichFilterFunction<AdClickEvent> { private transient LongCounter inputCounter; private transient LongCounter filteredCounter; @Override public void open(Configuration parameters) { inputCounter = getRuntimeContext().getLongCounter("input_count"); filteredCounter = getRuntimeContext().getLongCounter("filtered_count"); } @Override public boolean filter(AdClickEvent event) throws Exception { inputCounter.add(1); boolean isBlacklisted = BlacklistCache.contains(event.getUserId()); if (isBlacklisted) { filteredCounter.add(1); } return !isBlacklisted; } }然后在 Flink Web UI 的 Metrics 页面看这两个计数器的值。如果filtered_count一直是 0,要么是黑名单没加载成功,要么是测试数据里没有黑名单用户。我遇到过黑名单广播流因为序列化问题一直没数据的情况,就是靠这个计数器发现的。
5.3 统计结果的核对
窗口输出的统计结果,要和预期值核对。最简单的办法是用批处理跑一遍同样的数据,对比结果。如果实时和批处理的差值在允许范围内(考虑迟到数据),说明统计逻辑没问题。
另一个办法是抽样核对:从原始数据里随机抽几个广告位,手动数一下点击量,和 Flink 输出的结果对比。这个方法适合数据量不大的场景,能发现一些聚合逻辑的细节问题。
我踩过的一个坑是窗口边界问题:滚动窗口是左闭右开的,比如[00:00, 00:05),00:05:00 整点的数据会落到下一个窗口。如果业务方理解成闭区间,对账时就会差一条。这种问题一定要在联调阶段和业务方确认清楚。
5.4 压力测试与性能瓶颈定位
上线前必须做压力测试。我用的是自己写的压测脚本,往消息队列里灌数据,逐步加大 QPS,观察 Flink 任务的各项指标:
| 指标 | 正常范围 | 异常表现 | 可能原因 |
|---|---|---|---|
| 吞吐量 | 随 QPS 线性增长 | 增长停滞 | 算子成为瓶颈 |
| 背压 | 0 或低 | 持续高背压 | 下游处理慢 |
| Checkpoint 时长 | 稳定 | 逐渐变长 | 状态膨胀 |
| CPU 使用率 | 60% 以下 | 持续 90%+ | 计算密集或 GC |
| GC 时间 | 低 | 频繁 Full GC | 内存不足或泄漏 |
背压是最直观的指标。Flink Web UI 里每个算子都有背压指示,红色表示背压严重。如果过滤算子背压高,说明黑名单查询慢;如果窗口算子背压高,说明聚合逻辑重或者状态大。
我遇到过一次窗口算子背压,排查后发现是allowedLateness设得太长(10 分钟),导致窗口状态一直不释放。改成 1 分钟后背压立刻消失。这个参数不是越大越好,要根据数据迟到情况合理设置。
6. 几个容易翻车的细节和我的应对经验
6.1 黑名单数据的格式一致性
黑名单从风控系统同步过来,格式一定要统一。我遇到过风控系统有时候发 JSON、有时候发纯文本的情况,导致广播流解析失败,黑名单更新中断。解决办法是在广播流入口加一层格式校验,解析失败的数据写到侧流告警,不让它影响主流程。
DataStream<String> validatedBlacklist = rawBlacklist .process(new ProcessFunction<String, String>() { @Override public void processElement(String value, Context ctx, Collector<String> out) { try { // 尝试解析,验证格式 JSON.parseObject(value); out.collect(value); } catch (Exception e) { // 格式错误,输出到侧流 ctx.output(new OutputTag<String>("invalid-blacklist"){}, value); } } });6.2 黑名单删除的同步问题
黑名单不只有新增,还有删除(比如误判后解封)。如果广播流只发新增,删除操作就同步不过去。我的做法是广播流里带上操作类型,ADD表示加入黑名单,REMOVE表示移出。
@Override public void processBroadcastElement(BlacklistOp op, Context ctx, Collector<AdClickEvent> out) throws Exception { BroadcastState<String, Boolean> state = ctx.getBroadcastState(descriptor); if ("ADD".equals(op.getType())) { state.put(op.getUserId(), true); } else if ("REMOVE".equals(op.getType())) { state.remove(op.getUserId()); } }这个细节很容易被忽略,上线后才发现解封的用户还是被过滤,业务方投诉才回头改。
6.3 窗口结果输出的幂等性
统计结果写到下游存储时,要考虑幂等性。因为 Checkpoint 恢复后,窗口可能重新触发,同一个窗口的结果会输出两次。如果下游是数据库,用INSERT ... ON DUPLICATE KEY UPDATE或者先删后插来保证幂等。如果下游是消息队列,消费端要做去重。
我一般会在结果里带上窗口的起止时间,下游按广告位 + 窗口起始时间做主键,重复写入直接覆盖。
6.4 监控告警的配置
任务上线不是终点,监控才是。我必配的几个告警:
- Checkpoint 连续失败:超过 3 次就告警,可能是状态太大或存储有问题。
- 背压持续过高:某个算子背压超过 5 分钟,说明处理能力不足。
- 黑名单过滤量为 0:如果一段时间内过滤量突然归零,可能是黑名单同步断了。
- 任务重启:非预期重启要立即通知,排查是资源问题还是代码 bug。
监控指标可以从 Flink 的 Metrics 系统导出到 Prometheus,再用 Grafana 做面板。这套组合用起来很顺手,社区里资料也多。
6.5 资源规划的保守原则
最后说资源规划。我见过太多任务因为资源给少了,上线后频繁背压、Checkpoint 失败。我的原则是按峰值 QPS 的 1.5 倍规划资源,并且留出至少 30% 的内存余量给状态和 GC。
具体来说,如果峰值 QPS 是 10 万,单并行度能处理 1 万 QPS,那至少开 15 个并行度。内存方面,如果状态预估 2 GB,TaskManager 内存至少给 4 GB,留一半给堆外和 GC。这个配置不是拍脑袋,是压测出来的经验值。资源给多了浪费,给少了出事,宁可稍微宽裕一点。
广告点击量统计这个任务,技术栈不复杂,但细节特别多。黑名单过滤、窗口聚合、状态管理、容错恢复,每一环都有坑。我把这几年的经验整理出来,希望能帮你少走点弯路。实际落地时,建议先在测试环境把链路跑通,用模拟数据验证过滤和统计逻辑,再做压力测试确定资源配置,最后小流量上线观察一段时间。稳扎稳打,比一步到位靠谱得多。