☰
实时广告点击统计中的黑名单过滤与状态管理实战
2026/10/20 5:29:41 网站建设 项目流程

1. 实时统计场景下的黑名单过滤设计思路

广告点击量统计这件事,听起来像是数据开发里最基础的入门案例,但真把它放到生产环境里跑,你会发现坑远比想象中多。我接手过一个模拟项目,业务方要求实时展示各页面广告的点击量,同时要把已知的作弊用户从统计结果里剔除掉。表面上看就是"来一条数据、判断一下、累加一次"的事,但实际落地时涉及到的状态管理、过滤时机、数据倾斜、结果一致性等问题,每一个都值得单独拿出来聊。

这个项目的核心链路可以概括为:数据源持续产生广告点击事件流,事件经过黑名单过滤后,按页面维度做增量聚合,最终把统计结果输出到下游供展示或告警使用。关键词里的"黑名单过滤"不是简单加一个if判断就完事,它牵扯到过滤规则放在哪个环节、黑名单数据怎么加载和更新、过滤后是否会影响窗口计算的完整性等一系列决策。

适合谁来参考这篇内容?如果你已经了解流处理的基本概念,知道什么是DataStream、什么是KeyedState,但还没完整搭过一个带过滤逻辑的实时统计链路,那这篇会比较对路。如果你是完全零基础,建议先补一下流处理的基础模型再回来看,不然有些设计取舍你可能会觉得"为什么要这么绕"。

我下面会从整体架构选型开始,一步步拆到代码级别的实现细节,再把我踩过的几个典型坑和排查过程完整还原出来。所有代码和配置都基于常见实践做了合理补全,你可以直接拿去改改就能跑。

2. 整体架构与核心环节拆解

2.1 为什么选择事件驱动而非微批处理

实时广告点击统计,第一个要回答的问题就是:用真正的流处理还是用微批凑合?我见过不少团队一开始为了省事,用定时任务每隔几分钟扫一批数据出来算,结果业务方看到的数据永远是"几分钟前的",做实时大屏的时候延迟感非常明显。

事件驱动的方式,每条点击事件到达就立刻参与计算,延迟可以压到毫秒级。对于广告点击这种场景,实时性的价值在于:如果某个页面广告突然被异常流量刷了,你能在几秒内发现并做出反应,而不是等五分钟后的批处理结果出来才后知后觉。当然代价是系统复杂度上升,状态管理、容错、一致性都得自己扛。

我最终选的是基于DataStream的纯流式方案,没有用SQL层。原因很简单:黑名单过滤这个逻辑需要访问外部动态数据,而且过滤规则可能随时变化,用DataStream的RichFunction能更灵活地控制生命周期和状态访问。SQL层虽然写起来快,但在这种需要精细控制过滤时机的场景下,反而束手束脚。

2.2 黑名单过滤应该放在哪个环节

这是整个项目里最值得掰扯的一个设计决策。过滤可以放在三个位置:数据源接入后立刻过滤、聚合计算前过滤、聚合计算后过滤。三个位置各有优劣,我逐一分析一下。

接入后立刻过滤的好处是下游所有算子都不用再关心黑名单的事,数据干净。但问题是如果黑名单更新了,已经过滤掉的历史数据没法追溯,而且过滤算子会成为整个链路的瓶颈点,所有数据都要经过它。

聚合前过滤是我最终采用的方案。在keyBy之前做过滤,过滤完的数据再进入窗口或状态聚合。这样过滤逻辑和聚合逻辑解耦,黑名单更新时只需要影响后续到达的数据,已经聚合的结果不受影响。而且过滤算子可以并行处理,不会成为单点瓶颈。

聚合后过滤基本不考虑,因为聚合结果已经是按页面汇总的,你没法从汇总值里剔除某个用户的贡献,除非你保留每个用户的明细,那就失去了聚合的意义。

注意:过滤位置的选择会直接影响状态大小。聚合前过滤意味着进入状态的数据量更小,对于黑名单命中率高的场景,能显著降低状态后端的存储压力。

2.3 状态后端与检查点的取舍

广告点击量统计需要维护每个页面的累计点击量,这是一个典型的有状态计算。状态后端的选择直接影响到容错能力和性能表现。

我对比过三种常见方案:内存状态后端、文件系统状态后端、嵌入式数据库状态后端。内存方案性能最好,但状态大小受限于JVM堆内存,而且作业重启后状态全丢。文件系统方案通过异步快照把状态持久化到外部存储,兼顾了性能和容错,是我在这个项目里的选择。嵌入式数据库方案适合超大状态场景,但读写性能有额外开销,对于广告点击这种状态量级(页面数量通常几百到几千个)来说有点杀鸡用牛刀。

检查点的间隔我设的是30秒。太短了会增加系统开销,太长了故障恢复时重放的数据量太大。30秒是一个比较平衡的值,具体可以根据你的数据吞吐量和可接受的恢复时间来调整。我实测下来,在每秒几万条点击事件的吞吐下,30秒检查点对正常处理几乎没有影响。

3. 核心细节解析与实操要点

3.1 黑名单数据的加载与动态更新

黑名单不是一成不变的,业务方可能随时拉黑新的作弊用户,也可能把误判的用户放出来。所以黑名单必须支持动态更新,不能写死在代码里。

我的做法是在过滤算子里维护一份黑名单的本地缓存,同时起一个定时线程定期从外部存储刷新。为什么不用每条数据都去查外部存储?因为那样每条数据的处理都要等一次网络IO,吞吐量直接崩掉。本地缓存加定期刷新的方式,把查询成本摊薄到了刷新周期上。

具体实现上,我继承RichFlatMapFunction,在open方法里初始化缓存并启动刷新线程,在close方法里优雅关闭线程。刷新周期设的是60秒,这个值取决于业务对黑名单生效延迟的容忍度。如果业务要求拉黑后立刻生效,那就得把周期调短,或者改用消息通知的方式主动推送更新。

public class BlacklistFilter extends RichFlatMapFunction<AdClickEvent, AdClickEvent> { private transient volatile Set<String> blacklistCache; private transient ScheduledExecutorService scheduler; @Override public void open(Configuration parameters) { blacklistCache = new HashSet<>(); loadBlacklist(); scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(this::loadBlacklist, 60, 60, TimeUnit.SECONDS); } private void loadBlacklist() { // 从外部存储加载黑名单,更新到本地缓存 Set<String> latest = fetchFromExternalStore(); blacklistCache = latest; } @Override public void flatMap(AdClickEvent event, Collector<AdClickEvent> out) { if (!blacklistCache.contains(event.getUserId())) { out.collect(event); } } @Override public void close() { if (scheduler != null) { scheduler.shutdown(); } } }

这里有个细节值得注意:我用volatile修饰缓存引用,保证刷新线程更新后过滤线程能立刻看到新值。直接替换整个Set引用而不是逐个增删元素,避免了并发修改的问题。

3.2 点击量聚合的状态设计

聚合逻辑我选的是KeyedState而不是窗口。为什么?因为业务要的是"从作业启动到现在的累计点击量",而不是"最近五分钟的点击量"。窗口适合固定时间范围的统计,而累计值用ValueState更自然。

每个页面作为一个key,对应的ValueState里存一个Long类型的计数值。每来一条该页面的点击事件,就把状态值加一,然后输出更新后的结果。这种方式的优点是实现简单、状态小,缺点是每次都要输出全量结果,下游如果只是展示最新值,需要做去重或覆盖处理。

public class ClickCountAggregator extends RichFlatMapFunction<AdClickEvent, PageClickCount> { private ValueState<Long> countState; @Override public void open(Configuration parameters) { ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<>("clickCount", Long.class); countState = getRuntimeContext().getState(descriptor); } @Override public void flatMap(AdClickEvent event, Collector<PageClickCount> out) throws Exception { Long current = countState.value(); if (current == null) { current = 0L; } current += 1; countState.update(current); out.collect(new PageClickCount(event.getPageId(), current)); } }

如果你觉得每条都输出太频繁,可以加一个定时器,每隔几秒输出一次当前值。但那样会增加代码复杂度,而且实时性会打折扣。我个人的取舍是:如果下游是实时大屏,每条输出没问题;如果下游是数据库写入,最好加个缓冲或定时输出,避免写入压力过大。

3.3 数据倾斜的预防与处理

广告点击数据天然容易倾斜——热门页面的点击量可能是冷门页面的几百倍。如果直接按页面ID做keyBy,热门页面所在的分区会成为瓶颈,其他分区早早处理完在等它。

我试过两种缓解方案。第一种是两阶段聚合:先给key加一个随机前缀做局部聚合,再去掉前缀做全局聚合。这个方案能有效打散热点,但实现起来比较绕,而且状态会翻倍。第二种是给热门key单独开一个并行度更高的处理链路,冷门key走默认链路。这个方案更灵活,但需要提前知道哪些是热门key,或者动态检测热点。

最终我选了一个折中方案:在keyBy之前先做一次本地预聚合,把同一个并行实例内相同页面的点击先合并计数,再发送到下游。这样能减少网络传输量,但对真正的热点key帮助有限。如果你的场景里热点问题特别严重,建议直接上两阶段聚合,别犹豫。

提示:数据倾斜的判断不能只看总点击量,还要看单位时间内的点击速率。有些页面总量不大但某个时间段突然爆发,这种瞬时热点同样会把分区打爆。

4. 完整实操流程与关键配置

4.1 环境准备与依赖配置

这个项目我是在本地开发环境搭的,用Maven管理依赖。核心依赖就两个:流处理框架的运行时和连接器。版本选择上,我建议用较新的稳定版,老版本在状态管理和检查点方面有一些已知问题。

<dependencies> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-streaming-java</artifactId> <version>1.17.1</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-clients</artifactId> <version>1.17.1</version> </dependency> </dependencies>

开发环境用IDE直接跑就行,不需要额外搭集群。但要注意,IDE里跑的时候默认并行度是CPU核数,如果你本地机器核数多,日志会刷得很快,建议在代码里显式设置并行度为2或4,方便调试。

4.2 数据源模拟与事件定义

为了测试,我写了一个简单的数据源,模拟生成广告点击事件。事件包含用户ID、页面ID、广告ID、时间戳四个字段。用户ID里我故意混入了一些黑名单用户,用来验证过滤逻辑是否生效。

public class AdClickEvent { private String userId; private String pageId; private String adId; private long timestamp; // 构造器、getter、setter省略 } public class AdClickSource extends RichParallelSourceFunction<AdClickEvent> { private volatile boolean running = true; private Random random = new Random(); @Override public void run(SourceContext<AdClickEvent> ctx) throws Exception { String[] pages = {"page_home", "page_search", "page_detail", "page_cart"}; String[] users = {"user_001", "user_002", "user_003", "black_001", "black_002"}; while (running) { AdClickEvent event = new AdClickEvent( users[random.nextInt(users.length)], pages[random.nextInt(pages.length)], "ad_" + random.nextInt(10), System.currentTimeMillis() ); ctx.collect(event); Thread.sleep(10); } } @Override public void cancel() { running = false; } }

这个数据源每秒大概产生100条事件,其中黑名单用户占40%。这个比例是故意设高的,方便快速验证过滤效果。实际生产环境里黑名单命中率通常很低,可能不到1%。

4.3 主流程组装与参数设置

主流程的组装顺序是:数据源 -> 黑名单过滤 -> keyBy页面ID -> 状态聚合 -> 输出。检查点配置和状态后端配置也在这一步设置。

public class AdClickStatisticsJob { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(2); // 检查点配置 env.enableCheckpointing(30000); env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); env.getCheckpointConfig().setMinPauseBetweenCheckpoints(10000); env.getCheckpointConfig().setCheckpointTimeout(60000); // 状态后端配置 env.setStateBackend(new HashMapStateBackend()); env.getCheckpointConfig().setCheckpointStorage("file:///tmp/flink-checkpoints"); DataStream<AdClickEvent> sourceStream = env .addSource(new AdClickSource()) .name("ad-click-source"); DataStream<AdClickEvent> filteredStream = sourceStream .flatMap(new BlacklistFilter()) .name("blacklist-filter"); DataStream<PageClickCount> resultStream = filteredStream .keyBy(AdClickEvent::getPageId) .flatMap(new ClickCountAggregator()) .name("click-count-aggregator"); resultStream.print().name("result-sink"); env.execute("Ad Click Statistics with Blacklist Filter"); } }

并行度设2是因为本地调试方便看日志。检查点间隔30秒,最小间隔10秒,超时60秒。状态后端用HashMapStateBackend,检查点存到本地文件系统。生产环境的话,检查点存储应该换成分布式文件系统,状态后端可以考虑RocksDB。

4.4 运行验证与结果观察

启动作业后,控制台会持续输出每个页面的累计点击量。我观察到的现象是:黑名单用户的事件确实被过滤掉了,四个页面的点击量在稳步增长,而且因为数据源里黑名单占比高,过滤后的总点击量明显低于原始事件数。

验证过滤是否生效有个简单方法:在过滤算子后面加一个计数器,统计过滤前后的数量差。我加了一个LongCounter,每过滤掉一条就加一,作业运行一段时间后看计数器的值,和预期基本吻合。

// 在BlacklistFilter的flatMap方法里加计数器 private transient LongCounter filteredCounter; @Override public void open(Configuration parameters) { filteredCounter = getRuntimeContext().getLongCounter("filtered-count"); // ...其他初始化 } @Override public void flatMap(AdClickEvent event, Collector<AdClickEvent> out) { if (blacklistCache.contains(event.getUserId())) { filteredCounter.add(1); } else { out.collect(event); } }

这个计数器在Web UI上可以直接看到,非常方便做快速验证。如果你没有Web UI,也可以在日志里定期打印计数器的值。

5. 常见问题与排查技巧实录

5.1 黑名单更新后不生效

这是我最开始遇到的一个坑。黑名单刷新线程明明更新了缓存,但过滤逻辑还是用旧数据。排查后发现是缓存替换的可见性问题——刷新线程更新了引用,但过滤线程读到的还是旧引用。

解决办法前面提过,用volatile修饰缓存引用。但还有一个隐藏问题:如果你用的是可变集合,刷新线程在修改集合内容时,过滤线程可能读到中间状态。所以一定要用"构建新集合、替换引用"的方式,而不是在原集合上增删。

注意:如果你用的是分布式缓存而不是本地缓存,还要考虑缓存一致性的问题。不同并行实例的缓存刷新时间可能不同,导致同一时刻不同实例的过滤结果不一致。对于广告点击统计这种场景,短暂的不一致通常可以接受,但如果业务要求强一致,就得引入版本号或集中式过滤服务。

5.2 检查点失败导致作业重启

有一次作业跑了几个小时突然重启,日志显示检查点超时。排查后发现是状态太大,30秒内完不成快照。当时页面数量虽然不多,但每个页面的状态里我额外存了一些调试信息,导致状态膨胀。

解决办法很简单:把调试信息从状态里去掉,只保留必要的计数值。另外把检查点超时从60秒调到了120秒,给大状态快照留足时间。如果你也遇到检查点频繁失败,先看状态大小,再看检查点存储的写入性能,最后看网络带宽。

问题现象可能原因排查方向解决措施
检查点超时状态过大查看状态大小指标精简状态、增大超时
检查点超时存储写入慢检查存储IO指标更换存储介质
检查点失败网络抖动查看网络监控重试配置、增加超时
作业重启后状态丢失检查点未开启确认配置开启检查点并验证

5.3 数据倾斜导致个别分区延迟高

前面提过数据倾斜的问题,我实际遇到过一次:某个热门页面的点击量是其他页面的50倍,导致处理该页面的分区延迟明显高于其他分区。在Web UI上能看到各分区的处理速率差异很大。

当时的应急处理是临时提高了整体并行度,让热门页面有机会分到独立的分区。但这不是长久之计,因为并行度提高后冷门页面的分区会大量空闲。后来我改成了两阶段聚合,先加随机前缀做局部聚合,再去掉前缀做全局聚合。改造后各分区负载明显均衡了。

两阶段聚合的代码结构大概是这样的:

// 第一阶段:加随机前缀,局部聚合 DataStream<Tuple2<String, Long>> localAgg = filteredStream .map(event -> Tuple2.of(event.getPageId() + "_" + random.nextInt(4), 1L)) .keyBy(t -> t.f0) .sum(1); // 第二阶段:去掉前缀,全局聚合 DataStream<PageClickCount> globalAgg = localAgg .map(t -> Tuple2.of(t.f0.split("_")[0], t.f1)) .keyBy(t -> t.f0) .sum(1) .map(t -> new PageClickCount(t.f0, t.f1));

这个方案的代价是状态翻倍(每个页面有4个局部状态加1个全局状态),但换来了负载均衡。随机前缀的数量可以根据热点程度调整,热点越严重,前缀越多。

5.4 过滤逻辑误杀正常用户

有一次业务方反馈某个正常用户的点击没被统计到。排查后发现是黑名单里有一个用户ID和该正常用户ID只差一位,运维人员录入时手误打错了。这种问题没有技术上的完美解法,只能靠流程控制:黑名单录入加二次确认,定期审计黑名单命中情况,对命中率异常高的用户做人工复核。

我在代码里加了一个兜底逻辑:如果某个用户被过滤的次数超过阈值,就输出一条告警日志,提醒人工检查。这个阈值设的是1000次,正常黑名单用户不可能在短时间内产生这么多点击。

// 在过滤算子中增加异常命中告警 private transient Map<String, Long> filterCountMap; @Override public void flatMap(AdClickEvent event, Collector<AdClickEvent> out) { if (blacklistCache.contains(event.getUserId())) { long count = filterCountMap.merge(event.getUserId(), 1L, Long::sum); if (count == 1000) { // 输出告警日志 LOG.warn("User {} filtered {} times, please verify blacklist", event.getUserId(), count); } } else { out.collect(event); } }

这个map我放在堆内存里,没有用状态,因为告警逻辑不需要容错,作业重启后重新计数也没关系。如果你希望告警计数也持久化,可以改用ValueState,但那样会增加状态大小,需要权衡。

6. 性能调优与生产化建议

6.1 并行度与资源配比的确定方法

并行度不是拍脑袋定的,得根据数据吞吐量和单并行度的处理能力来算。我的经验值是:单并行度每秒处理5000到10000条简单事件比较轻松,如果涉及复杂计算或外部IO,可能降到1000到2000条。

假设你的峰值吞吐是每秒5万条,那并行度至少设5到10。但还要考虑状态大小和检查点开销,并行度太高会导致检查点协调成本上升。我一般会先设一个保守值,跑起来后看Web UI的背压指标,如果某个算子显示HIGH背压,就适当提高它的并行度。

资源配比方面,每个TaskManager的slot数建议设成CPU核数的一半到相等。内存方面,状态后端用HashMapStateBackend的话,JVM堆内存要留足,一般建议状态大小的3到5倍。用RocksDB的话,堆外内存要单独配置。

6.2 黑名单缓存的容量规划

本地缓存的黑名单如果太大,会占用大量内存;如果太小,又会导致频繁刷新。我的一般建议是:如果黑名单条目在百万级以内,直接全量缓存在内存里没问题,一个用户ID按50字节算,百万条也就50MB。如果超过千万级,就得考虑分片缓存或者布隆过滤器了。

布隆过滤器的问题是存在假阳性——可能把正常用户误判为黑名单。对于广告点击统计,偶尔误杀一两个正常用户的点击,影响不大。但如果业务对准确性要求极高,就不能用布隆过滤器,得用精确的数据结构。

我目前的做法是:黑名单条目少于500万时用HashSet全量缓存,超过500万时改用分片缓存,每个并行实例只缓存自己负责的那部分用户。分片规则和keyBy的分片规则保持一致,这样过滤时不需要跨实例查询。

6.3 监控指标与告警配置

生产环境跑起来后,必须配监控。我重点关注的指标有四个:数据延迟、吞吐量、检查点成功率和状态大小。数据延迟反映实时性是否达标,吞吐量反映处理能力是否足够,检查点成功率反映容错是否可靠,状态大小反映资源是否够用。

告警阈值我设的是:数据延迟超过10秒告警,吞吐量下降超过30%告警,连续两次检查点失败告警,状态大小超过阈值告警。这些阈值需要根据业务实际情况调整,不能照搬。

另外建议加一个业务层面的监控:各页面的点击量环比变化。如果某个页面的点击量突然暴涨或暴跌,可能是数据源出了问题,也可能是黑名单误杀或漏杀。这种业务监控比技术监控更能提前发现问题。

提示:监控指标不要只看平均值,要看分位数。平均延迟1秒可能掩盖了部分数据延迟100秒的事实。P99延迟才是真正反映用户体验的指标。

6.4 作业升级与状态兼容

业务需求会变,代码要升级,但作业不能停。流处理作业的升级比批处理麻烦得多,因为涉及状态兼容。我踩过的坑是:升级时改了状态的数据结构,结果新作业启动时无法从旧检查点恢复,只能从零开始跑。

避免这个问题的关键是:状态的数据结构一旦上线,就尽量不改。如果非要改,得用状态迁移工具或者自定义序列化器做兼容。我现在的习惯是,状态里只存最基础的类型(Long、String、简单POJO),复杂结构通过外部存储关联,这样升级时状态兼容性压力小很多。

如果确实需要改状态结构,我的做法是:先停旧作业,保存一个savepoint,然后用新代码从savepoint恢复。如果新代码不兼容旧状态,就写一个一次性迁移程序,把旧状态读出来转换成新格式再写回去。这个过程比较繁琐,但比重新跑历史数据要快得多。

广告点击统计这个场景,状态结构其实很稳定,就是页面ID到计数值的映射。真正容易变的是黑名单过滤逻辑和输出格式,这些都不涉及状态,升级起来很轻松。所以我在设计时刻意把易变逻辑和状态分离,状态层保持极简,业务逻辑层随便改。这个原则在流处理作业设计里非常实用,值得你在做类似项目时参考。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询