工作后做了一段时间的「SQL Boy」,从 2020 年底开始不满足于现状,正好业务上实时需求越来越多,于是开始学习实时计算。从用 Java 写底层 API,到回归 SQL,再到和 Python 结合,算是对 Flink 有了一定的了解。这篇文章回顾这段经历,既是自省,也给后来者一点参考。

这段路上遇到的几个问题,后来各自写成了一篇文章,所以这篇也是 Flink 这组文章的入口,文末有一张对照表。

整条路线大致是这样的:

我学 Flink 的路线:先在 DataStream API 里手动管状态,再回到 SQL 与生态

从一个监控需求开始

当时业务上有一个监控场景:游戏里一个玩家每天能获得的奖励是有限的,需要实时盯住玩家获得物品的情况,超过阈值就预警,及时处理。2020 年底 Flink 已经是实时计算的事实标准,选它顺理成章。而我们的实时平台当时主要用 Java 写应用,所以第一个 Flink 程序是用底层 API 写的。

Flink 提供了不同抽象层次的 API,越往上越省事,越往下越灵活:

层次

代表

状态谁来管

适合

底层

ProcessFunction

自己管:存什么、何时清

逻辑特殊、上层表达不了

中层

DataStream + Window

窗口结束时框架清理

有窗口语义的聚合

上层

Table API / SQL

优化器和框架管

绝大多数统计类需求

底层 API:ProcessFunction

用底层 API 写 Flink 程序,最难的是状态。实时作业是长期运行的,状态如果只增不减,迟早会把内存撑爆。这里用 MapState 模拟按天的窗口,并注册处理时间定时器,到点清理过期的状态:

java
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:窗口结束时框架会自动清理窗口状态。唯一的问题是窗口默认在结束时才触发计算,而监控需要一超阈值就报警,所以要自定义一个触发器:

java
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 写法。可以看到,状态的保留时间变成了一个参数,其余都交给了框架:

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 的时机。

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 里加载并调用用户的脚本就行:

python
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 与窗口

规则天天在变,能不能只改配置、不重启作业?

Flink 规则引擎

上层 API:SQL

结果会不断更新,下游怎么知道旧结果作废了?

Flink SQL 回撤机制与问题

PyFlink

线程模式为什么能让 Java 算子直接调用 Python 函数?

Java 调用 Python

流批一体

实时和离线能不能只写一份逻辑、只算出一个数?

我理解的流批一体

想动手跑一跑:规则引擎和回撤的示例代码在 QFlink 仓库里。

参考资料


全文评论

0

正在加载评论…