
前阵子有个做实时风控的朋友问我“Flink CEP是不是只能靠Java/Scala写SQL到底能不能做CEP”这个问题每隔一段时间就会被翻出来。我的答案很直接能但不是靠一个叫“CEP SQL”的独立组件而是靠Flink SQL里的MATCH_RECOGNIZE子句。如果你正打算用Flink处理登录风控、交易反欺诈、设备异常这类“先发生A、再发生B、最后发生C”的序列型事件“flink cep sql”这几个关键词展开来就是一套用SQL实现复杂事件匹配的完整思路。这篇内容我从几个角度来拆Flink CEP和SQL的关系、MATCH_RECOGNIZE语法怎么啃、一个完整的登录风控案例怎么做、以及我实际跑作业时踩过的坑。适合用Flink做实时规则计算、又想把业务规则SQL化的开发者参考。我不讲太多虚的直接落到能跑、能复用的细节上。1. Flink CEP SQL到底是什么两套能力别搞混1.1 它们不是一个组件别被叫法误导先说清楚Flink CEP和Flink SQL里的CEP能力在实现路径上是两套东西。Flink CEP通常指的是DataStream API下的一套复杂事件处理库核心是Pattern API。你写一段Java/Scala代码用begin、next、followedBy、within这些方法描述一个模式然后注册到底层CEP算子中由算子完成事件流的匹配、超时处理、状态维护。它灵活、精细但需要开发人员直接面对DataStream编程模型。而Flink SQL里的CEP能力是通过标准SQL语法中的MATCH_RECOGNIZE子句实现的。它也被叫做“行模式识别”简单说就是在一张有顺序的事件流表上用类似正则表达式的方式描述你要找的行序列。Flink会把这段SQL翻译成分布式执行计划匹配逻辑照样在状态后端里跑只是对外暴露的是SQL语义。所以“flink cep sql”这个关键词准确理解应该是我能不能用SQL的写法去实现原本CEP才能干的事情。答案是可以但能力和API版CEP并不完全等价。1.2 为什么大家都在问“SQL能不能做CEP”我接触过不少团队实时规则这块一开始都是用Pattern API私人定制规则越多代码越臃肿。业务侧的规则可能每周都在调阈值、加步骤、改条件每一次都要走开发、测试、发版流程。后来大家发现如果规则能用SQL写出来那相当于把“规则”变成了“一条可读的查询语句”业务同学起码能看懂开发自测也快很多。另一个原因是Flink SQL生态太方便了。做CEP之前你经常会需要先做清洗、过滤、维表关联、窗口聚合这些逻辑用SQL写非常顺手。如果能直接在上游结果上继续写MATCH_RECOGNIZE整个链路都是SQL架构会干净很多团队也不需要同时养DataStream API和SQL两拨人。1.3 先记住一个能力边界我在项目里最常说的一句话是SQL解决80%的规则场景但剩下20%别硬扛。SQL方式的MATCH_RECOGNIZE适合表达“有顺序、有条件、有时间窗”的序列匹配。但如果你的规则涉及超时后主动告警、动态阈值计算、每个匹配都要触发自定义回调、或者在匹配过程中做复杂的逐事件判断这些就超纲了。遇到这类需求要么回到Pattern API要么组合使用后面第4节我会专门讲选型。2. 把CEP SQL用明白先啃下MATCH_RECOGNIZE这几块2.1 一个SQL做CEP的整体结构MATCH_RECOGNIZE的语法看起来吓人但拆开就几块。直接看骨架SELECT * FROM input_table MATCH_RECOGNIZE ( PARTITION BY account_id ORDER BY event_time MEASURES ... [ONE ROW PER MATCH | ALL ROWS PER MATCH] [AFTER MATCH SKIP ...] PATTERN (A B C?) [WITHIN INTERVAL 10 MINUTE] DEFINE A AS condition1, B AS condition2, C AS condition3 );这里每个关键字都有明确作用PARTITION BY按什么维度分组相当于把流拆成很多独立的小流每一个key内部单独做模式匹配。ORDER BY事件排序字段通常用事件时间或处理时间。MEASURES匹配完成后要输出哪些字段。ONE ROW PER MATCH / ALL ROWS PER MATCH匹配结果按“一个模式一行输出”还是“把模式里所有命中行都输出”。PATTERN核心的模式表达式字母只是变量名你可以理解为正则表达式里的字符。WITHIN整个模式匹配允许的时间跨度。DEFINE给PATTERN里的每个变量定义判定条件。这段结构和正则匹配的思路很像。你在字符串里用abc文本搜索会找到“a后跟一个或多个b再跟一个c”的片段在事件流里PATTERN就是在找“一个满足A条件的事件后面跟着满足B条件的一串事件再后面可能有一个满足C条件的事件”。这个类比适合记语法。2.2 PATTERN量词和连续性是核心难点PATTERN里最常用的是这几种量词我直接列出来A恰好一个满足A条件的事件。A?0个或1个。A*0个或多个。A1个或多个。A{3}恰好3个。A{3,}至少3个。A{2,5}2到5个。实际书写时我更喜欢组合着用。比如检测“先登录成功再连续失败3次以上”PATTERN就是succ fail{3,}。succ代表成功登录那次fail{3,}代表3次及以上的连续失败。还有一个特别容易踩坑的点连续性问题。默认情况下PATTERN里的相邻变量是“严格连续”的也就是说succ后面必须紧接着就是fail事件中间不能插入其他事件。如果业务要求“成功登录之后10分钟内出现3次失败中间可能有其他操作记录”那你就不能直接写succ fail{3,}了。这时候需要引入一个“任意事件”变量PATTERN (succ any* fail{3,}) DEFINE any AS TRUE, succ AS login_type success, fail AS login_type failany AS TRUE表示任何行都可以匹配any这样就把严格连续模式变成了“松散连续”模式。这个技巧看着简单但很多人一开始想不到结果规则怎么跑都命中不了。2.3 DEFINE里能写什么、不能写什么DEFINE子句负责给每个变量定条件条件是一个布尔表达式。它可以引用当前行的字段也可以引用前面已经匹配到的变量行字段。比如你想表达“失败事件必须发生在成功事件的5分钟之后”可以在DEFINE里这样写DEFINE succ AS login_type success, fail AS login_type fail AND ts succ.ts INTERVAL 5 MINUTE这是个很实用的写法适合做相对时间约束。但要注意DEFINE里不能引用未来的事件也不能随便引用还没匹配到的变量。整体判断逻辑必须从前向后串起来这点和CEP API的condition语义是一致的。2.4 输出策略结果到底怎么查匹配完成之后最常用的是ONE ROW PER MATCH也就是每个匹配只输出一行概要。比如我只关心“哪个账号、从什么时间开始、到什么时间结束”那MEASURES里写清楚就行MEASURES succ.ts AS start_ts, LAST(fail.ts) AS end_ts这里有个容易忽略的细节如果模式变量fail是或*这种可能匹配多行的取它的字段时最好用LAST(fail.ts)或FIRST(fail.ts)显式指定取哪一行。如果你直接写fail.ts在部分版本里会报错或者取到第一行语义不清楚。ALL ROWS PER MATCH则用于把匹配命中的每一行都输出适合下游还要继续处理明细的场景。这种模式下你可以用CLASSIFIER()函数看到每行匹配到了哪个变量方便做分支判断。3. 完整实操用Flink CEP SQL检测“连续3次登录失败”3.1 数据源和结果表怎么建我习惯先在Flik SQL Client里把source和sink定义好。下面是一个标准的Kafka事件流建表语句字段很简单账号ID、登录类型、事件时间。CREATE TEMPORARY TABLE login_events ( account_id STRING, login_type STRING, ts TIMESTAMP(3), WATERMARK FOR ts AS ts - INTERVAL 5 SECOND ) WITH ( connector kafka, topic login_events, properties.bootstrap.servers localhost:9092, properties.group.id cep-sql-demo, format json, scan.startup.mode earliest-offset );很多新手上来就把time字段当普通时间忘记声明WATERMARK。在流场景里事件可能乱序如果不声明watermarkMATCH_RECOGNIZE的ORDER BY时间语义就会出问题该匹配的序列匹配不到这是首批要排查的坑。结果表先用print连接器直接把匹配结果打到日志里CREATE TEMPORARY TABLE cep_result ( account_id STRING, start_ts TIMESTAMP(3), end_ts TIMESTAMP(3) ) WITH ( connector print );3.2 核心SQL一个模式检测规则接下来直接写业务逻辑。需求是同一账号在10分钟内先发生一次成功登录然后至少连续3次失败判定为风险行为。INSERT INTO cep_result SELECT account_id, start_ts, end_ts FROM ( SELECT * FROM login_events MATCH_RECOGNIZE ( PARTITION BY account_id ORDER BY ts MEASURES account_id AS account_id, succ.ts AS start_ts, LAST(fail.ts) AS end_ts ONE ROW PER MATCH AFTER MATCH SKIP PAST LAST ROW PATTERN (succ fail{3,}) WITHIN INTERVAL 10 MINUTE DEFINE succ AS login_type success, fail AS login_type fail ) );执行后如果上游topic里有符合条件的事件序列就会输出类似下面的结果A1001, 2024-01-15 10:00:00, 2024-01-15 10:00:30这里的AFTER MATCH SKIP PAST LAST ROW含义是匹配完成后跳到本次匹配最后一行之后继续寻找下一轮避免同一个事件重复参与多个匹配导致告警刷屏。如果业务需要重叠检测也可以换成AFTER MATCH SKIP TO NEXT ROW但结果会明显变多需要慎重。3.3 同样的规则Pattern API大概要写多少为了让你对SQL的收益有体感我简单对比一下。Pattern API版核心代码是这样的Pattern.LoginEventbegin(succ) .where(event - event.loginType.equals(success)) .next(fail) .timesOrMore(3) .where(event - event.loginType.equals(fail)) .within(Time.minutes(10));再看外层的keyBy、CEP.select、source/sink定义加起来一个能跑的文件大概一百多行而且只描述了一个规则。如果规则一变就要改代码重新部署。SQL版本的老实说也有开发成本但可读性和迭代便利性确实好很多。3.4 联调辅助不要只靠datagen随机灌数做本地联调时我踩过一个大坑用Flink自带的datagen连接器随机生成登录事件结果无论如何都匹配不出“成功后再失败3次”的序列。原因很简单随机数据很难构造出连续的、有业务含义的序列。所以我的建议是联调时写一个小脚本往Kafka里按顺序发送一组构造好的数据。数据样例大概是这样的A1001, success, 2024-01-15 10:00:00 A1001, fail, 2024-01-15 10:00:05 A1001, fail, 2024-01-15 10:00:10 A1001, fail, 2024-01-15 10:00:20 A1002, success, 2024-01-15 10:00:00 A1002, fail, 2024-01-15 10:00:02 A1002, success, 2024-01-15 10:00:30第一组A1001会命中A1002因为只有一次fail不会命中。用这种可控数据验证规则语义比随机灌数高效得多也更容易定位问题。3.5 别忘了Kafka分区和并行度的影响如果你用Kafka做source有个物理执行层面的细节要清楚。MATCH_RECOGNIZE会在PARTITION BY字段上做keyBy也就是说无论上游Kafka怎么分区数据最终都会按account_id重新分组到对应子任务里。Flink不会因为Kafka分区不规则而漏匹配但会产生网络shuffle影响吞吐。如果你的topic本身已经按account_id作为key写入让source端和下游的key分布尽量一致能减少不少shuffle开销。尤其是账号数量大、事件量大的场景这一点对性能很关键。4. CEP SQL和Pattern API选型别硬上SQL4.1 一张表看清取舍很多人在选型时会纠结。我干脆排了一张对比表供你参考维度SQL MATCH_RECOGNIZEDataStream CEP API开发效率高规则像语句中需要写代码表达能力中等适合常见序列强支持精细控制超时回调不支持支持动态规则更新作业重启成本高可设计规则热更新上下游生态天然支持SQL join/聚合需要自己组装DataStream调试便利性SQL里一眼看逻辑需要断点、日志状态开销有但隐藏较深能部分手动控制团队门槛低高这条表的核心是如果你的团队以SQL开发为主复杂度可控用MATCH_RECOGNIZE能大幅降低沟通成本。如果规则已经复杂到要用超时、回调、逐事件状态机API几乎是唯一选择。4.2 哪些场景我劝你别硬用SQL我遇到过几个需求一看就知道SQL会很难受。一是“超时未命中也得告警”。SQL的WITHIN只能让匹配在时间窗内结束超时就丢弃没法在超时的时候通知你。这种要告警的必须要用CEP API里的within配handleTimeout机制。二是“阈值会动态变化”。比如fail次数的上限不是写死3次而是要根据账号历史行为动态计算。SQL里DEFINE条件虽然可以引用前序事件但要支撑复杂动态逻辑会非常别扭。API版可以直接在condition里调用外部计算逻辑灵活很多。三是“匹配过程中要主动更新外部状态”。比如命中后实时拉黑账号、通知风控系统。SQL只能输出结果后续动作需要下游Consumer再做而API可以在process函数里直接整合。4.3 更常见的其实是混合架构在实际项目里我很少见到“非此即彼”的纯SQL或纯API方案更多是混合。比如先用SQL完成Kafka接入、字段过滤、维表补全、窗口聚合输出一条“候选风险事件”流然后再把这条流送入CEP API做精细模式匹配。这样既利用了SQL的开发效率又把CEP里最灵活的部分交给API两边都不难受。如果你也打算用“flink cep sql”落地我建议先想清楚哪些规则是固定框架下的参数调整尽量SQL化哪些规则真的需要程序逻辑介入单独走API。这样比一上来就想把所有规则都塞进SQL要靠谱。5. 跑在Flink CEP SQL上的那些坑我帮你踩过了5.1 乱序数据导致该命中的没命中现象明明测试数据完全符合规则作业跑起来就是不出结果。原因watermark设置太严格或太松。watermark表示“这个时间之前的数据都已到达”如果延迟设置为INTERVAL 5 SECOND那事件时间越晚越容易因为等待超时被丢弃或未参与匹配。上游数据如果本身时间戳乱序很大甚至会直接没匹配机会。处理方式一是尽量用事件时间和watermark让乱序数据有等待窗口二是给source设置空闲超时防止数据稀疏时watermark一直不推进三是先用单分区Kafka做测试排除乱序干扰。我当时排查了很久才发现是测试脚本把时间戳发乱了数据清洗后规则立刻生效。5.2 状态膨胀比想象中来得快常驻作业跑几天后Flink状态越来越大最终影响吞吐甚至导致反压。原因通常是模式量词没有上限比如PATTERN里写fail*或者fail理论上只要失败事件不停就能匹配到天荒地老状态一直累积。遇到这种规则我会做两个动作。一是给明显有界的模式加上限比如fail{3,20}超过20次就不再继续匹配状态能及时释放。二是给作业配置RocksDB状态后端再把状态的TTL调到一个合理值避免无界增长。这里注意不同Flink版本的状态TTL参数名有差异一定要以你当前版本的支持列表为准。5.3 热点账号拖垮整个作业PARTITION BY account_id如果某个账号的事件量是普通账号的几千倍就会出现单key热点。一个subtask忙死其他subtask空转作业整体吞吐上不去。我处理过一个极端case某个对外接口被脚本刷量一个账号每天产生大量请求事件结果CEP作业在这个key上成了瓶颈。后来方案是把事件流先按会话维度拆拆分后再做PARTITION BY保证单key粒度足够细热点明显缓解。如果你的场景也有大key建议提前设计好分组维度不要无脑按账号分。5.4 匹配数量比预期多多半是AFTER MATCH SKIP的锅默认的AFTER MATCH SKIP PAST LAST ROW是跳过整个已经匹配的序列不容易产生重叠告警。但如果你用了SKIP TO NEXT ROW同一个事件可能参与多个匹配。比如fail{3,}在连续5次失败里会识别出多次“3连败”的窗口下游告警就会重复。业务上如果只需要“每次风险事件序列只告警一次”就用PAST LAST ROW。如果业务确实需要滑动式检测那重叠结果也要去重或合并否则报警平台会骂人。这是线上非常常见的“看起来SQL对结果却怪怪的”原因之一。5.5 版本差异同一个SQL换个版本结果变了MATCH_RECOGNIZE在Flink的不同小版本里行为有过调整。比如时间字段的精度、WITHIN子句支持的写法、状态TTL的默认值都可能不一样。我吃过一次亏测试环境Flink版本和线上相差一个小版本SQL明明是同一套测试匹配结果和线上不一致。排查到最后发现是时间精度解析差异。建议是项目一开始就固定Flink版本SQL里所有时间字段统一格式上线前在目标版本环境跑一遍回归样例。不要只在测试环境验证完就部署。5.6 一个小技巧先做单分区小数据回归不管你的SQL看起来多简单第一次上线我都建议用“单线程状态”的方式做验证把topic迁到单分区或者用过滤条件限定几个账号然后逐个预期输出。逻辑确认无误后再放开全量流量。这招看起来慢其实最能节省时间。Flink CEP SQL的匹配语义有很多隐含的默认行为不跑真实数据永远不知道哪里会出乎意料。6. 再进一步CEP SQL规则治理与灰度上线6.1 规则不要散落在代码里SQL化的一个好处是规则可以被当成配置管理。我在某个项目里会把模板SQL和参数分开存在配置中心或数据库里。规则参数比如“失败次数阈值”“时间窗口长度”作为占位符提交作业时再渲染成完整SQL。这样业务调整阈值时只需要改配置不需要动Flink作业代码。但这不等于运行时动态更新。Flink SQL作业一旦启动规则就固定了要改规则仍然需要停止、改配置、重新提交。如果你需要纯热更新SQL这条路目前并不可靠还是得回到API或配合动态Pattern管理。6.2 新规则上线先用旁路对比再切换实时规则最怕误杀尤其是风控场景。我的习惯是把新规则先写到旁路比如输出到另一个Kafka topic和现有规则结果做对比。先观察一段时间确认新增命中都是合理的再把流量切到新规则。旁路对比还要重点看两类差异新规则多出来的命中以及新规则漏掉的旧命中。多出来的可能因为pattern条件放宽漏掉的可能是连续性判断太严这些都是上线前要解释清楚的问题。6.3 给规则加上版本号规则在跑业务方一直在改需求你可能会同时维护多个版本的规则。我会在每个匹配输出里加一个rule_version字段这样下游看结果时知道是哪一版规则产生的。排查纠纷时特别有用也方便回滚。实际做法很简单在MEASURES里写一个固定的字符串字段即可MEASURES account_id AS account_id, succ.ts AS start_ts, LAST(fail.ts) AS end_ts, login_risk_v1 AS rule_version这个字段看着不起眼但线上定位问题的时候它会帮你省非常多时间。讲句实话我见过不少团队一开始雄心勃勃想把所有CEP逻辑都塞进SQL最后又悄悄退回API。我倒不觉得这是失败反而是把能力边界摸清了。我自己现在的习惯是能在一个MATCH_RECOGNIZE里表达清楚的优先SQL涉及超时回调、动态阈值、多级嵌套这类强定制逻辑直接趁早用API。如果你也正在往“flink cep sql”这条路走建议先拿一个最小的“成功-连续失败”例子跑通把watermark、状态、AFTER MATCH SKIP这几个关键概念吃透再逐步把规则从简单往复杂推。实时计算的坑大多不在SQL怎么写而在数据时序和运行状态怎么管这两块稳了大部分问题都能提前躲开。