工作后做了一段时间的「SQL Boy」,从 2020 年底开始不满足于现状,正好业务上实时需求越来越多,于是开始学习实时计算。从用 Java 写底层 API,到回归 SQL,再到和 Python 结合,算是对 Flink 有了一定的了解。这篇文章回顾这段经历,既是自省,也给后来者一点参考。
这段路上遇到的几个问题,后来各自写成了一篇文章,所以这篇也是 Flink 这组文章的入口,文末有一张对照表。
整条路线大致是这样的:
从一个监控需求开始
当时业务上有一个监控场景:游戏里一个玩家每天能获得的奖励是有限的,需要实时盯住玩家获得物品的情况,超过阈值就预警,及时处理。2020 年底 Flink 已经是实时计算的事实标准,选它顺理成章。而我们的实时平台当时主要用 Java 写应用,所以第一个 Flink 程序是用底层 API 写的。
Flink 提供了不同抽象层次的 API,越往上越省事,越往下越灵活:
层次 | 代表 | 状态谁来管 | 适合 |
|---|---|---|---|
底层 | ProcessFunction | 自己管:存什么、何时清 | 逻辑特殊、上层表达不了 |
中层 | DataStream + Window | 窗口结束时框架清理 | 有窗口语义的聚合 |
上层 | Table API / SQL | 优化器和框架管 | 绝大多数统计类需求 |
底层 API:ProcessFunction
用底层 API 写 Flink 程序,最难的是状态。实时作业是长期运行的,状态如果只增不减,迟早会把内存撑爆。这里用 MapState 模拟按天的窗口,并注册处理时间定时器,到点清理过期的状态:
public class MyProcessFunction extends KeyedProcessFunction<String, Tuple3<String, Long, Integer>, String> {
private static final int THRESHOLD = 100;
// 窗口开始(当天 0 点)之后 28 小时,即次日凌晨 4 点清理
private static final long CLEAR_INTERVAL = 28 * 60 * 60 * 1000L;
// 窗口聚合状态:<窗口开始时间戳, 聚合值>
private transient MapState<Long, Integer> aggState;
@Override
public void open(Configuration config) throws Exception {
MapStateDescriptor<Long, Integer> descriptor =
new MapStateDescriptor<>("Aggregation", Long.class, Integer.class);
aggState = getRuntimeContext().getMapState(descriptor);
}
@Override
public void processElement(Tuple3<String, Long, Integer> value, Context ctx, Collector<String> out)
throws Exception {
// 状态聚合:按事件所在的那一天累加
long windowStart = getWindowStartWithOffset(value.f1);
Integer agg = value.f2;
if (aggState.contains(windowStart)) {
agg += aggState.get(windowStart);
}
aggState.put(windowStart, agg);
// 阈值判断
if (agg > THRESHOLD) {
out.collect("Alert Happens!");
}
// 状态清理:为这一天注册一个清理定时器(同一时间点重复注册只会触发一次)
ctx.timerService().registerProcessingTimeTimer(windowStart + CLEAR_INTERVAL);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
aggState.remove(timestamp - CLEAR_INTERVAL);
}
/** 东八区的当天 0 点。 */
private long getWindowStartWithOffset(long timestamp) {
long offset = 8 * 60 * 60 * 1000L;
long windowSize = 24 * 60 * 60 * 1000L;
long remainder = (timestamp + offset) % windowSize;
return remainder < 0 ? timestamp - (remainder + windowSize) : timestamp - remainder;
}
}一开始既不熟悉 Java,也不熟悉 Flink 的 API,踩了不少坑:状态没清理干净、出了问题很难调试、代码组织混乱。这个经历告诉我:刚接触一个框架时,应该先从上层 API 用起,让框架替你挡住底层的复杂性。
随着经验增多,我也在不断改进代码结构:
执行环境和作业参数模块化:默认按最佳实践设置。比如生产环境的 Kafka 经常扩缩容,一定要打开分区动态发现,否则新增分区的数据会被漏掉。
连接器可插拔:自定义连接器,不同环境只需要换参数。
中层 API:Window
有没有不用自己管状态的写法?翻文档发现可以用 Window API:窗口结束时框架会自动清理窗口状态。唯一的问题是窗口默认在结束时才触发计算,而监控需要一超阈值就报警,所以要自定义一个触发器:
dataStream.keyBy(value -> value.f0)
// 按天滚动,偏移 -8 小时对齐东八区的 0 点
.window(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
.trigger(new Trigger<Tuple3<String, Long, Integer>, TimeWindow>() {
private static final int THRESHOLD = 100;
private final ValueStateDescriptor<Integer> stateDesc =
new ValueStateDescriptor<>("agg", Integer.class);
@Override
public TriggerResult onElement(Tuple3<String, Long, Integer> element, long timestamp,
TimeWindow window, TriggerContext ctx) throws Exception {
// 更新累计值
ValueState<Integer> aggState = ctx.getPartitionedState(stateDesc);
int agg = element.f2 + (aggState.value() == null ? 0 : aggState.value());
aggState.update(agg);
// 超过阈值立即触发,而不是等到窗口结束
return agg >= THRESHOLD ? TriggerResult.FIRE : TriggerResult.CONTINUE;
}
@Override
public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
@Override
public void clear(TimeWindow window, TriggerContext ctx) throws Exception {
ctx.getPartitionedState(stateDesc).clear();
}
})
// ReduceFunction 只保留一个元素,而不是窗口里的全部元素
.reduce((value1, value2) -> value2,
new ProcessWindowFunction<Tuple3<String, Long, Integer>, String, String, TimeWindow>() {
@Override
public void process(String key, Context context,
Iterable<Tuple3<String, Long, Integer>> elements, Collector<String> out) {
out.collect("Alert Happens");
}
});上层 API:SQL
当时并没有想到用 Flink SQL,这里为了完整,补上同样逻辑的 SQL 写法。可以看到,状态的保留时间变成了一个参数,其余都交给了框架:
-- 状态保留 2 天
SET 'table.exec.state.ttl' = '2 d';
SELECT
DATE_FORMAT(`time`, 'yyyyMMdd') AS `window_start`,
`key_word` AS `key`,
MAX(`time`) AS `time`,
SUM(`key1`) AS `metric`
FROM `dwd_log`
GROUP BY DATE_FORMAT(`time`, 'yyyyMMdd'), `key_word`
HAVING SUM(`key1`) > 100;换成 SQL 以后,还有一件 Java 版本里看不到的事:这条 GROUP BY 没有窗口,结果不是在一天结束时输出一次,而是每来一条数据就更新一次。下游已经收到的旧结果怎么办?Flink 会先发一条「之前那条作废」(-U),再发新值(+U),这就是回撤。它保证了结果正确,也带来了乱序和中间错误结果两个副作用,见 Flink SQL 回撤机制与问题。
再往前一步:规则引擎
做需求的同时也要有技术沉淀。接手了几个类似的监控需求后,我开始想:能不能不再每次都开发代码,只配置规则就马上生效?于是有了 Flink 规则引擎:
用广播流把规则下发到每个算子,规则改了立即生效;
仿照
WindowOperator的原理,实现可配置的自定义窗口。
不过 DataStream API 的滑动窗口是按窗口复制元素的:窗口长 1 天、每分钟滑动一次,同一条数据要放进 1440 个窗口,状态和定时器都会膨胀。Flink SQL 的窗口表值函数则用切片(slice)实现:把时间切成不重叠的小片,每片只聚合一次,滑动窗口的结果由若干切片合并而成,中间结果可以复用。详见 Flink SQL 窗口表值函数聚合实现原理浅析。后来 QFlink 的规则引擎也改成了按切片聚合,做法见 Flink 规则引擎;切片为什么成立(聚合能合并),见 少算一点:大数据计算优化的统一视角。
Flink SQL:用优势说服别人
当团队已经习惯了一种技术,想引入新技术,就必须把它的优势讲清楚。Flink SQL 的引入,是因为它在累计在线人数这个问题上体现出了明显的优势。
为什么引入
最开始我们用底层 API 维护 Bloom Filter 来去重计数。数据规模大幅增长后出现了数据倾斜,大量数据挤在一个节点上,作业经常出问题。这时 Flink 1.13 刚好发布,新增了累积窗口(CUMULATE),而且只要打开一个参数,就能自动把 COUNT DISTINCT 拆成两阶段,解决倾斜问题——正是引入 Flink SQL 的时机。
-- 自动把 COUNT DISTINCT 拆成两阶段聚合,缓解倾斜
SET 'table.optimizer.distinct-agg.split.enabled' = 'true';
SELECT
DATE_FORMAT(`window_start`, 'yyyy-MM-dd HH:mm:ss') AS `window_start`,
DATE_FORMAT(`window_end`, 'yyyy-MM-dd HH:mm:ss') AS `window_end`,
`key_word` AS `dim`,
COUNT(DISTINCT `key2`) AS `metric`
FROM TABLE(
-- 每分钟输出一次「当天 0 点到现在」的累计值
CUMULATE(TABLE `dwd_log`, DESCRIPTOR(`rowtime`), INTERVAL '1' MINUTES, INTERVAL '1' DAYS))
GROUP BY `key_word`, `window_start`, `window_end`;拆成两阶段之所以还能算对,是因为按用户 ID 分桶后,同一个人只落进一个桶,桶与桶之间没有重复。去重计数为什么通常不能这样拆、什么时候又可以,见 大数据去重统计 和 少算一点。
当时实时平台对 Flink SQL 的支持还很有限,但正因为之前写过底层 API,我可以用 Table API 包一层,做到「只写 SQL」的效果。
随着业务对实时数据稳定性的要求越来越高,我们也做了一些保障工作:
平台当时还不支持动态扩缩容,就通过详细压测得出合适的作业参数;
配置作业状态、消费延迟等监控,第一时间发现异常;
迁移到专有集群,避免互相干扰。
Flink SQL SDK
要推广 Flink SQL,就得降低上手门槛,同时补齐功能,于是我开发了一个 Flink SQL SDK:
SQL 与 Java 代码解耦:SQL 统一管理,开发者虽然仍要克隆 Java 工程,但只需写 SQL 就能运行,不用写一行 Java;
动态加载 UDF 和连接器:自定义
ClassLoader动态加载 Jar 包,不改基础代码也能增强 SQL 的表达能力,思路可参考 FlinkSQL 动态加载 UDF 实现思路;替换存量作业:用 UDTF 加自定义的 Kafka 多 Topic Sink,实现了原来原生作业「从全量数据中按关键字解析、分发到不同 Topic」的功能,同时绕开了 UDF 被重复调用的问题。
后来实时平台不断发展,可以直接通过 SQL 网关提交 SQL,这个 SDK 也就完成了使命,我又「退化」成了 SQL Boy。但这次是从底层出发给 SQL 赋予更多能力,算是在「SQL Is All Your Need」的路上留下了一点足迹。相关的做法后来整理成了 Flink SQL、Flink SQL UDF 和 Flink Dynamic SQL 几篇文章。
PyFlink:接入 Python 生态
数据分析和 AI 让 Python 越来越流行,Flink 和 Python 生态结合会带来更多可能。在游戏反外挂的场景里,需要按不同策略处理数据特征,实时筛出开挂的玩家进行处罚。这些策略一般是安全人员用 Python 写的脚本,还会有复杂的算法模型——正好是 PyFlink 的舞台。
为什么引入
最早我们用 Jython 让 Python 跑在 JVM 里,但 Jython 缺乏维护、兼容性很差,有时甚至要手动把 Python 代码翻译成 Java。实时平台支持 PyFlink 之后,只要在 UDF 里加载并调用用户的脚本就行:
class DemoUDF(ScalarFunction):
def open(self, function_context: FunctionContext):
model_path = "your model path"
script_path = "your python script path"
with open(script_path, "r") as f:
code = f.read()
# 让用户脚本里定义的函数进入全局命名空间
exec(code, globals())
self.model = user_open(model_path)
def eval(self, value):
return user_eval(self.model, value)两种执行模式
PyFlink 的 Python UDF 有两种执行模式:
模式 | 原理 | 优点 | 代价 |
|---|---|---|---|
进程模式 | Python 函数跑在独立进程,和 Java 算子通过 gRPC 通信 | 资源隔离好 | 每条数据都要跨进程序列化 |
线程模式 | 通过外部函数接口(FFI)把 Python 解释器嵌进 JVM 进程 | 省掉序列化,性能更好 | 隔离性较差 |
线程模式背后的跨语言调用原理,可以看 Java 调用 Python 一文。实践中也遇到了几个问题:
算子链:写原生 Flink 应用时会尽量把算子链在一起,省掉算子间的序列化;但有 Python 算子时要断开算子链,让 Python 算子拿到足够的资源。
CPU 密集:原生 Flink 应用大多是内存密集型的,Python 算子却很可能是 CPU 密集型的,要单独调高 CPU 资源。
实现方式:在相同资源下,用 PyFlink + DataStream API 和 Flink SQL + Python UDF 跑同一个作业,后者效率差很多。可能是平台资源控制的问题,可惜至今没有找到答案。发现这个问题是因为 Flink SQL + Python UDF 可以复用已有的 Java 基础代码,而改用 PyFlink 提交作业就要把这些代码全部改写成 Python。
流批一体
大数据组件越来越多:存储有 HDFS、Kafka,计算有离线的 Spark、实时的 Flink,开发和维护成本都很高。最麻烦的是实时和离线的一致性。比如在安全业务中,要离线统计算法策略命中人员的概率分布,就得把实时代码全部改写成 PySpark,费时费力。
而流批一体是 Flink 的特性:只换连接器,同一份代码就能以批模式离线运行,大大提升了开发效率。简单对比过,相同资源下 Flink 批模式的效率甚至比 Spark 还高。再加上正在兴起的数据湖,存储也能做到流批一体。未来对普通开发者来说,也许不会再区分实时和离线,一份 SQL 就能完成各种数据开发,那时真正的挑战将是对业务的理解。后来我对这个话题有了更系统的思考,写在 我理解的流批一体 里。
总结
从写离线 SQL 进入大数据领域,到不满足于做 SQL Boy、开始接触 Flink,从底层 API 一路写上来,最后又回到了 SQL。兜兜转转回到原点,但回来时已经知道 SQL 背后的状态、窗口和执行模式是怎么一回事了。也许以后大数据系统会回归为数据密集型的分布式系统,统一用 SQL 甚至自然语言开发;希望在这段旅途中,也留下自己的足迹。
这段经历留下的问题
阶段 | 留下的问题 | 在哪篇回答 |
|---|---|---|
底层 API 与窗口 | 规则天天在变,能不能只改配置、不重启作业? | |
上层 API:SQL | 结果会不断更新,下游怎么知道旧结果作废了? | |
PyFlink | 线程模式为什么能让 Java 算子直接调用 Python 函数? | |
流批一体 | 实时和离线能不能只写一份逻辑、只算出一个数? |
想动手跑一跑:规则引擎和回撤的示例代码在 QFlink 仓库里。
全文评论
0正在加载评论…