1. 从“判断”到“预言”:CEP到底解决了什么难题
先聊一个很容易被忽略的问题:普通流处理框架处理一条条数据,为什么还要专门搞一个CEP?直接用状态编程不就行了吗?
我见过不少同学第一次接触Flink CEP时,脑子里是懵的——“正则表达式”我懂,字符匹配嘛,但流里的“正则表达式”是什么鬼?
打个比方你就明白了。你在一个电商平台上盯着用户行为日志,普通流处理做的事是:当用户A下单了,给我发一条告警。但运营真正关心的问题是:用户在5分钟内,依次经历了“浏览商品页 → 加入购物车 → 提交订单 → 支付成功”这一整串动作,最后才能判断这是一个高意向转化用户。你如果盯着单条日志看,永远拼不出这个完整故事。
CEP(复杂事件处理)干的就是这件事:它把流里的每个事件当作一个“字符”,把你要找的事件序列模式当作“正则表达式”,然后在那条无穷无尽、实时流动的数据河里,帮你把符合模式的片段捞出来。
Flink在流处理领域能站住脚,除了那套精确一次的状态一致性机制外,CEP能力也是一个非常重要的加分项。尤其在做实时风控、实时监控告警、用户行为轨迹分析这几类场景时,CEP几乎是绕不开的工程选项。这篇博文我就用尽量大白话的方式,把这套机制从理论到代码、从易错点到排查思路全部拆开来讲,争取你看完能直接拿去用。
2. 正则和CEP的类比:这可能是理解CEP最舒服的一个姿势
2.1 字符匹配和事件匹配是一回事
我们写正则表达式时,脑子里想的是“字符串”:
ab+c表示一个a,后面跟着一个或多个b,最后跟一个c。a{3}表示连续三个a。^abc表示必须以abc开头。
CEP里的Pattern API,本质上就是把上面那一套逻辑,从“字符”平移到了“事件”上。比如你定义这样一个模式:
登录事件 → 登录失败事件 → 登录失败事件 → 登录成功事件这翻译过来,就是正则里的login fail{2} success。流里如果出现了这样的事件序列,CEP就会给你弹出一个“匹配成功”的告警。你在正则里会用的“连续出现”“至少出现一次”“可选出现”“时间窗口内出现”这些概念,CEP全部都有对应表达。
所以很多讲CEP的教材都会直接用“流式正则表达式”这个说法。我自己带过不少新人,发现只要把这个类比讲清楚了,CEP的入门门槛基本就被削掉一大半。
2.2 但流和字符串有三个本质差异
不过类比归类比,流处理和字符串处理有三个本质差异,如果你忽略这几个差异,后面写出的CEP代码多半有问题。
第一个差异是无限性。字符串是有限的,正则匹配是“跑完整个字符串,看有没有命中的部分”。但流是无限的,你不能等整个流结束再匹配,你永远等不来“结束”。所以CEP的匹配方式,是事件一进来就尝试推进模式,能匹配则输出,不能匹配就随着时间老化淘汰。它没有“回头再搜一遍”的余地,数据过去了就过去了。
第二个差异是时间维度。字符串里字符之间的位置关系只有“前后”,但流事件之间天然带着时间戳。这意味着CEP里不仅要说“A后面跟着B”,还要说“A和B之间的时间差不能超过10秒”。这就是within子句存在的意义——给模式加一个时间笼子,防止两个隔了一个小时的事件也被硬凑成一对。
第三个差异是连续性要求。正则匹配的字符天然是连续的,但流事件中间可能插入大量无关事件。所以Flink CEP为事件之间的关系定义了严格的“近邻”语义:严格近邻意味着中间一个别的事件都不能有,宽松近邻则允许中间插入别的干扰事件。具体选择哪一种,直接影响你最后匹配到的序列长什么样。
理解了这几个差异,你就理解了CEP的设计骨架。
3. Pattern API三大件:个体模式、组合模式、时间约束
3.1 个体模式:单条事件的“筛选条件”
Pattern API里最基础的一个概念是个体模式(Individual Pattern)。它描述的是“什么样的单个事件能够进入匹配流程”,本质上就是对单条事件做过滤。
写法非常直白:
Pattern.<Event>begin("start") .where(new SimpleCondition<Event>() { @Override public boolean filter(Event value) throws Exception { return value.getType().equals("login"); } });这一段定义了一个叫start的个体模式,它只关心type字段等于login的事件。注意这里的where,就是给个体模式加过滤条件,语义上等同于SQL的where,不等同于Flink DataStream里的filter操作——因为CEP的where是在模式匹配框架内部执行的,它决定的是“这个事件能不能作为模式第一步的候选”,而filter是直接把事件从流里扔掉,两者目的完全不同。
个体模式还支持叠加条件:
Pattern.<Event>begin("start") .subtype(LoginEvent.class) .where(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getUserId() > 0; } }) .or(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getLevel() >= 3; } });这里面出现了两个新东西:subtype负责限定事件的Java类型,or负责添加“或”逻辑的第二个条件。组合起来表达的意思就是:事件类型得是LoginEvent,并且(用户ID大于0 或 用户等级不低于3)。这套条件叠加机制,基本上覆盖了你在实际业务里遇到的大多数单事件过滤需求。
3.2 组合模式:事件之间的“先后关系”
把个体模式一个个串起来,就有了组合模式(Combining Pattern)。串的方式有三种,对应三种不同的事件间关系,这也是新手最容易搞混的地方。
先看最常用的两个:
// 严格近邻:A后面必须紧跟着B,中间不能有任何事件 Pattern.<Event>begin("A") .next("B"); // 宽松近邻:A后面跟着B,但中间可以插入其他事件 Pattern.<Event>begin("A") .followedBy("B");严格近邻next要求事件在流里的位置是连续的,中间一个别的都不能有。宽松近邻followedBy则允许中间隔着一堆事件,只要“后面某个时刻出现了B”就算匹配成功。
还有第三个关系叫followedByAny,它比followedBy更宽松:允许匹配到的事件序列之间发生重叠。举个具体例子,流里的事件顺序是:A B C B。用A.followedBy(B)去匹配,只会匹配到第一个A和后面的第一个B;但用A.followedByAny(B)去匹配,A可以和第一个B匹配,也可以和第二个B匹配,输出两套匹配结果。
在实际业务里,这三种关系的选择直接决定匹配结果。比如你要监测“用户登录后30分钟内下单”,中间用户可能浏览了几十件商品,那就必须用followedBy,不能用next,否则用户随便浏览一件商品你的模式就断了。反过来,如果监测的是“连续三次登录失败后出现登录成功”,那应该用next,因为中间哪怕是夹了一条无关日志都意味着这次攻击序列被其他操作打断了。
3.3 量词和时间约束:给模式加上“次数”和“期限”
正则里最常用的量词是*和+,CEP里也有对应的东西。Pattern API里用times和oneOrMore来表达:
// A事件恰好出现2次 Pattern.<Event>begin("A") .times(2); // A事件连续出现2次或更多次 Pattern.<Event>begin("A") .times(2, 4); // A事件出现1次或多次(贪婪匹配) Pattern.<Event>begin("A") .oneOrMore(); // A事件出现2次或多次,且是宽松近邻 Pattern.<Event>begin("A") .timesOrMore(2);写到这里有个非常重要的细节我要强调:times和oneOrMore在Flink CEP里默认是宽松近邻的。什么意思呢?A.times(2)匹配的是“连续出现的两个A”这个直觉对不对?不对,在CEP的默认语义下,这是“出现的两个A”,中间允许插入别的类型的事件。要让它变成真正意义上的“连续出现”,你得写A.next(A).times(2)或者使用consecutive()方法。
我当年第一次用times(2)做连续失败登录检测,结果因为中间插了一条心跳事件导致误报,排查了很久才发现是这个默认语义坑的。这里提前帮你们踩掉。
时间约束的写法则简单直接:
Pattern.<Event>begin("A") .followedBy("B") .where(...) .within(Time.seconds(30));within(Time.seconds(30))的意思很直白:从模式第一个事件进入匹配到最后一个事件完成匹配,整个事件序列的时间跨度不能超过30秒,超时就不算。实际使用中,我几乎没见过不写within的CEP模式,因为不限制时间的模式,在一个长时间运行的流上,会把相隔几个小时、毫无业务因果关系的事件强行拼接在一起,产出大量无意义的结果。
4. 从模式到匹配结果:PatternStream与Select/Process
4.1 把模式套到流上
定义好模式之后,下一步是把模式“套”到数据流上。DataStream API里这一步长这样:
DataStream<LoginEvent> loginStream = ...; Pattern<LoginEvent, LoginEvent> pattern = Pattern .<LoginEvent>begin("start") .where(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getType().equals("fail"); } }) .next("end") .where(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getType().equals("success"); } }) .within(Time.seconds(10)); PatternStream<LoginEvent> patternStream = CEP.pattern(loginStream, pattern);得到的PatternStream就像一个被正则标记过的输入流,它本身没有立刻触发计算,而是等着你去定义“匹配到之后怎么办”。
这里有个容易被忽略的点:CEP.pattern传入的DataStream,最好是已经按事件时间或处理时间排好序的流。如果事件乱序严重,CEP内部的匹配引擎处理起来会困难很多,因为模式匹配本质上是依赖事件顺序的。如果你用的是事件时间,建议在进入CEP之前先做一次assignTimestampsAndWatermarks,把水位线机制配好;如果你用的是处理时间,Kafka分区的顺序天然有序,通常问题不大。
4.2 用Select提取最终结果
Select是把匹配到的模式序列转换成一个输出结果的最简单方式:
DataStream<String> result = patternStream.select( new PatternSelectFunction<LoginEvent, String>() { @Override public String select(Map<String, List<LoginEvent>> pattern) throws Exception { LoginEvent first = pattern.get("start").get(0); LoginEvent last = pattern.get("end").get(0); return "检测到快速登录成功,用户ID:" + first.getUserId() + ",失败事件ID:" + first.getId() + ",成功事件ID:" + last.getId(); } } );注意select函数的入参是一个Map<String, List<LoginEvent>>,key是你在模式里定义的事件名(比如这里的start和end),value是对应这个模式名命中的所有事件列表。为什么是List?因为如果模式里某个位置用了times或者oneOrMore,命中的就不止一条事件了,所以统一用List来装。如果你确认某个模式名只会命中一条事件,稳妥的做法是取get(0)前先判断一下isEmpty(),防止空指针。
4.3 用Process处理更复杂的输出逻辑
Select虽然方便,但它的局限是只能拿到“匹配成功”的事件,拿不到“超时”的部分事件序列。很多场景下,比如风控里用户只做了两步操作就放弃了,你恰恰需要把这部分“未完成”的事件也记录下来做分析。这时候就得用ProcessFunction。
DataStream<String> result = patternStream.process( new PatternProcessFunction<LoginEvent, String>() { @Override public void processMatch( Map<String, List<LoginEvent>> match, Context ctx, Collector<String> out) throws Exception { // 匹配成功时回调 } } ); DataStream<String> timeoutResult = patternStream.process( new PatternTimeoutFunction<LoginEvent, String>() { @Override public String timeout( Map<String, List<LoginEvent>> pattern, long timeoutTimestamp) throws Exception { // 匹配超时时回调,partial pattern可以在这里拿到 } } );这里PatternTimeoutFunction拿到的Map是“不完整的模式匹配”。比如你定义了一个三步模式A B C,结果A和B出现了,C在within时间内没出现,这时候超时回调里就能拿到A和B,然后你可以把这条“半截”记录单独写入另一个流,用于后续分析用户为什么没有走完整个链路。
新版Flink里process接收的是PatternProcessFunction,老版本里的select配合OutputTag的超时写法已经逐渐不被推荐了,新写逻辑统一用process更干净。
4.4 一步到位的完整示例:登录失败快速重试检测
为了让你把上面所有概念串起来,我写一个完整的、可直接改造使用的示例。场景是:检测某个用户ID在2分钟内,出现至少3次登录失败后紧跟着1次登录成功,判断为可疑的暴力破解行为。
DataStream<LoginEvent> loginStream = source .assignTimestampsAndWatermarks( WatermarkStrategy.<LoginEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5)) .withTimestampAssigner((event, timestamp) -> event.getTimestamp()) ); Pattern<LoginEvent, LoginEvent> pattern = Pattern .<LoginEvent>begin("fail") .where(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getType().equals("fail"); } }) .times(3) .consecutive() .next("success") .where(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getType().equals("success"); } }) .within(Time.minutes(2)); PatternStream<LoginEvent> patternStream = CEP.pattern(loginStream.keyBy(LoginEvent::getUserId), pattern); DataStream<String> alerts = patternStream.process( new PatternProcessFunction<LoginEvent, String>() { @Override public void processMatch( Map<String, List<LoginEvent>> match, Context ctx, Collector<String> out) { List<LoginEvent> fails = match.get("fail"); LoginEvent success = match.get("success").get(0); out.collect("用户 " + success.getUserId() + " 在2分钟内连续失败" + fails.size() + "次后登录成功,存在撞库嫌疑,请及时核查。"); } } ); alerts.print();这里面的.consecutive()就是上面提到的“连续出现”的显式声明,配合times(3)表达的是连续三次失败,中间不能插入任何其他事件。keyBy(LoginEvent::getUserId)则保证了同一个用户ID的登录事件会被分到同一个CEP引擎实例里去匹配——这是CEP能做用户维度分析的基础,不按用户分组的话,不同用户的事件会混在一起,匹配毫无意义。
5. 四种实战场景与模式设计套路
5.1 场景一:实时风控中的“撞库”检测
风控是CEP最经典的应用领域之一。撞库攻击的典型特征是:某个用户ID在短时间内,出现“大量登录失败 → 一次登录成功”的事件序列。除了上面写的模式,风控场景里还经常用followedByAny来做“非连续但有关联”的序列。比如“同一IP在5分钟内登录了10个不同的用户ID”,这个模式不要求登录事件在流里连续,只需要在时间窗口内满足数量条件。
这类模式的核心写法是用oneOrMore配合until来限定条件:
Pattern.<LoginEvent>begin("login") .where(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getIp().equals(targetIp); } }) .oneOrMore() .until(new SimpleCondition<LoginEvent>() { @Override public boolean filter(LoginEvent value) throws Exception { return value.getUserId().equals(blockedUser); } }) .within(Time.minutes(5));until表达的是“匹配什么时候停止”。没有until的话,oneOrMore会一直贪婪地匹配下去,直到遇到不符合条件的事件才停。until显式指定了停止条件,适合表达“直到出现某个终止信号为止”这类业务语义。
5.2 场景二:交易链路监控中的“支付超时”
电商场景里,用户加购后没有支付,或者支付链路里卡在某一步迟迟没走完,这类问题的发现方式就是超时事件的捕获。
模式定义:加购物车 → 提交订单 → 支付,用within限制时间,比如10分钟。如果10分钟内支付事件没有出现,PatternTimeoutFunction就会触发,你在这个回调里把订单号、当前环节、耗时全部记录下来,发给告警系统。
这里有个实践心得:超时时间不能设得太短,否则用户稍微犹豫一下就被误报成异常;也不能设得太长,否则异常交易链路的发现就失去时效性。各业务差异太大,我一般建议先拿历史数据跑一遍离线统计,看正常链路耗时的90分位数,把它作为within的初始值,上线后再逐步收敛。
5.3 场景三:IoT设备监控中的“连续异常上报”
工业物联网场景里,设备每隔几秒上报一次运行数据。运维关心的是“设备连续上报5次温度异常,然后上报了一次正常”。times(5).consecutive()在这里就特别合适,因为设备上报是高频且有序的,严格近邻的语义正好符合“连续异常”的业务定义。
这类场景要注意的是数据源的质量。设备上报经常有断点,一旦中间漏了一条数据,consecutive()就会导致匹配中断,整条序列全部作废。所以实践上很多IoT监控会把严格近邻改写成times(5)不加consecutive,配合within做时间约束,效果反而更贴近业务——设备断点本身可能就是异常的一种表现,不该因为它导致监测失效。
5.4 场景四:用户行为路径分析中的“高意向判断”
最后一个高频场景是用户路径分析。比如你想识别“浏览了商品详情页 → 收藏了商品 → 30分钟内再次回到商品详情页 → 下单”的高意向用户。
这个场景里事件之间往往不是严格连续的,用户可能中途看了别的东西,所以用followedBy更合适。同时因为用户路径天然存在多种可能,建议在模式里把不同阶段的事件名拆细一点,方便后面做归因分析。还要注意,这类分析业务的数据量一般很大,模式匹配会占用不少状态存储,建议给每个匹配模式设置合理的TTL,避免状态无限增长把内存耗尽。
6. 新手最容易踩的四个大坑
6.1 坑一:把严格近邻和宽松近邻搞混
这是我在评审代码时最常见的问题。不少同学写“订单支付成功后30分钟内退款”这种模式,下意识用了next把“支付成功”和“退款”连在一起。结果线上跑了一段时间,发现漏报率特别高。原因很简单:用户支付成功之后,往往先看了订单详情页、点了确认收货,然后才发起退款,中间插了好几条事件,next要求的“紧密相邻”根本不满足。
正确的语义应该用followedBy,因为订单支付和退款之间允许插入其他行为。反过来,如果检测的是“连续访问接口5次”这种需要绝对连续的操作,用followedBy就会出现把间隔很久的两次访问误判成一次序列的问题。
一个非常实用的判断方法:问自己“中间如果插入一条无关事件,这次业务还算不算同一次行为”。算,就用宽松近邻;不算,就用严格近邻。
6.2 坑二:与依赖顺序的时序混淆
CEP匹配引擎不管你是处理时间还是事件时间,它关心的是事件在流里的到来顺序和你指定的时间字段顺序是否一致。如果数据源存在比较大的乱序,而你用的是事件时间CEP,则务必在进入CEP之前用WatermarkStrategy做好乱序容忍设置。否则会出现:逻辑上应该匹配上的事件,因为乱序被水位线机制提前判定为过期,导致匹配失败。
我自己的经验是,Kafka来源的数据一定要先检查消息的timestamp字段设置是否合理。有些消息在源头写入时就用的是处理时间,导致下游事件时间意义失真,这种问题在CEP里比在窗口计算里更隐蔽,排查起来也痛苦得多。
6.3 坑三:忽略状态大小导致内存被打爆
CEP模式匹配需要把流中间产生的大量“部分匹配序列”先缓存起来,等后续事件来补齐。一个激进模式的oneOrMore().times(10),在极端情况下可能同时维护数以万计的部分匹配状态。如果并行度又很高,状态后端是堆内存,直接OOM给你看。
规避手段有三个。一是尽量用within把状态的有效期圈起来,让超时的部分匹配及时被清理。二是给模式加约束条件,减少进入匹配流程的事件量,比如前置加一两个低成本的过滤事件。三是改用RocksDB状态后端,宁可牺牲一些访问性能,也别把堆内存耗尽。这三招都做了之后,我这边线上CEP作业基本没再遇到状态膨胀的问题。
6.4 坑四:错误的超时只完成一半
PatternTimeoutFunction的设计是:只拿得到“超时那一刻为止已经匹配到的部分模式序列”。有些同学误以为超时回调里能把一整条序列都拿到,于是在回调里写死了取pattern.get("last"),结果每次拿到的都是null,排查半天才发现超时事件里根本没有最后一个模式的事件。
正确的做法是:超时回调里只能访问已经完成匹配的那些模式名。比如模式是A B C,超时发生在C没来,那回调里能拿到的是A和B,C永远不存在。如果你需要在超时时知道“卡在哪个环节”,反而应该好好利用这个特性,根据Map里有哪些key就能推断出链路走到了哪一步。
7. 从CEP到更高阶的复杂事件处理
最后聊点个人体会。Flink CEP目前适合解决“事件序列模式清晰、条件明确”的问题,但如果你面对的是“依赖规则不断变化、需要实时更新匹配策略”的场景,纯CEP的Pattern API在动态更新上会有些笨重。这时候业界有几种常见演进路径:一种是把规则配置外置化,通过数据库或配置中心动态下发,代码里用占位符参数化模式条件;另一种是干脆在CEP之上套一层规则引擎,把复杂条件判断放在CEP外面做。
我自己在项目中试过第一种方案,效果还可以。做法是把where里的判断条件全部改成从配置中心读取的表达式,每次规则变更只需要推配置,不需要重启作业。代价是表达式解析本身会带来额外性能损耗,但换来的是业务方自助调整规则的能力,整体性价比很高。
如果你刚接触CEP,我建议的学习路线是:先把Pattern API的三种关系(next、followedBy、followedByAny)和量词语义吃透,然后在本地用一个模拟数据流把上面的登录检测例子跑起来,再去理解超时和状态管理。这套基础打牢之后,再去看源码里的NFA(非确定有限自动机)实现,你会发现CEP在Flink内部本质上就是一个流式NFA匹配引擎,和正则引擎在思路上完全一致——只是它会永远运行下去而已。