第一次接触 Apache Wayang 是在一次交流会上,当时有个问题让我印象很深:“如果业务代码里全是 Spark API,跑了一年之后发现 Flink 在某些场景更省资源,你打算花多久把代码改过去?”台下没人敢接话。这正是当下开源数据处理生态最微妙的痛点——引擎不稀缺,稀缺的是“不被引擎绑架”的自由。Apache Wayang 这个项目,给我的第一印象就是它在正面回应这件事:与其再造一个更强的执行引擎,不如做一层引擎指挥层,让同一套业务代码可以在 Spark、Flink、PostgreSQL、本地 Java 流等不同平台间自动选择、甚至可以拆开分配到多个引擎上同时执行。这篇文章我会从设计思路、内部执行链路、实际跑通的代码,以及我在真实任务里踩过的坑,完整聊聊它到底值不值得用。
1. 开源数据处理生态越繁荣,选型反而越纠结
1.1 每个框架都在某一环很强,却很难通吃
最近经常看到有人在搜“王者荣耀实时数据处理怎么做到的”这类问题,背后的潜台词是:最好有个足够强的引擎,把高吞吐流式计算、离线批处理、即席查询、机器学习训练全包了。但现实是,开源数据处理框架各自占据一个山头:Spark 在批处理和统一分析上生态最全,Flink 在流式数据处理上有口皆碑,Trino/Presto 擅长联邦查询和交互式 SQL,ClickHouse 扛得住高并发聚合,PostgreSQL 在关系型数据建模上依然能打。
这些框架没有一个能在所有场景下都做到最优。更麻烦的是,它们之间的 API 互不兼容。今天你用 Spark 的 DataFrame 写了一套清洗逻辑,明天想换到 Flink SQL,表面上是改方言,实际上依赖、调优参数、UDF 注册方式全都要推倒重来。我在团队里见过太多类似的项目:初期技术选型时大家拍脑袋选了一个框架,半年后业务阶段变化,发现当初的选择成了负担,可这个时候代码已经长成了“代码屎山”,重构的代价比迁移本身还高。
1.2 真正的瓶颈不是引擎能力,而是“绑定”
如果把数据处理任务比作出行,Spark 是轿车,Flink 是地铁,PostgreSQL 是公交车专用道。每个工具都有优势路段,但如果你从出发那一刻就选定“只坐轿车”,遇到拥堵、限行、维修路段也只能硬扛。现实里更合理的做法是分段规划:这段路坐地铁、那段路打车、最后几百米步行。
Apache Wayang 想做的,就是那个智能出行规划器。它不参与具体的运输,只负责帮你判断哪一段路用哪个交通工具更划算,并在规划完成后调度对应的交通工具执行。放到数据处理语境下,它做的是“引擎中立化”——你把业务逻辑用 Wayang 的接口写一遍,它负责在运行时决定:这个 filter 是丢给 Spark 还是本地流处理,这个 join 是下推到 PostgreSQL 还是跑在 Flink 上。
这听起来像中间层,但和传统中间层有个本质区别:它不是做统一的“减法”,而是做动态的“组合”。同一个任务可以被拆成多个子任务,交给不同引擎并行执行。这才是它真正让我觉得有价值的地方。
1.3 为什么现有的“统一”方案总觉得差一口气
有人可能会说,这不就是 SQL 联邦查询吗?Trino 不是已经可以把 MySQL、Hive、ClickHouse 当成一张表来查了吗?没错,但 Trino 这种方案解决的只是“查询”层面,而且它自己是执行引擎,需要把数据拉过来自己算。Wayang 的范围更宽——它不仅面向 SQL,还面向 RDD 风格的算子、数据处理管道和机器学习算子;它也不只是“路由整个任务给某个引擎”,而是能在算子粒度上做切分。
另一个容易混淆的对象是 Apache Calcite。Calcite 是做 SQL 解析、校验和优化的通用框架,很多引擎内部用它做优化器。但 Calcite 面向的是“帮你把 SQL 优化成更好的执行计划”,Wayang 面向的是“在多引擎之间选择最优执行者”。如果把 Calcite 比作导航算法,Wayang 就是那个同时管理出租车、地铁、飞机的大调度平台。二者可以结合,但不是同一层的东西。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Apache Wayang 的解耦思路:不造引擎,做引擎指挥层
2.1 核心定位:从“选一个引擎”变成“注册一批引擎”
Wayang 的顶层设计思路其实很朴素:把“数据处理逻辑”和“执行引擎”解耦。你在代码里不直接调用 SparkContext 或 StreamExecutionEnvironment,而是使用 Wayang 提供的统一算子,例如 loadCollection、filter、map、reduceByKey、join、count。这些算子先构成一个平台无关的逻辑计划(WayangPlan),再由 Wayang 内部的优化器把逻辑计划翻译成一到多个平台上的具体执行计划。
用户要做的事情是“注册引擎”。你可以注册一个本地的 JavaStream 插件,也可以注册 Spark、Flink、PostgreSQL 等插件,甚至可以同时注册多个。注册完之后,写业务代码时完全不用关心数据最终跑在哪个引擎上。这个体验很像我第一次用 Spark 时的感觉——把 SparkSession 当成入口,其他都交给框架。但 Wayang 的“Session”背后不是绑定某个固定引擎,而是一组可动态选择的引擎。
这种设计有个很实际的收益:它把“技术选型”这件事从项目初期推迟到了运行时。你今天只注册 JavaStream 和 Spark,明天发现业务需要更合适的引擎,只要新增一个插件,业务代码基本不用动。对于怕选错技术栈的团队来说,这是一条很现实的退路。
2.2 组成模块一览
Wayang 的模块划分很清晰,我整理了一张表,方便你快速建立全局印象:
| 模块 | 主要作用 | 日常类比 |
|---|---|---|
| WayangContext | 用户入口,负责持有配置、注册 Plugin | 机场总服务台 |
| WayangPlan | 平台无关的逻辑执行计划 | 不带司机的导航路线 |
| Plugin | 把某个具体引擎接入 Wayang 的适配器 | 各类交通工具的接入协议 |
| Platform | 对某个执行引擎的抽象描述 | 交通工具本身 |
| Optimizer | 选择算子最优实现并生成跨平台执行计划 | 出行规划算法 |
| CardinalityEstimator | 估算算子输入数据量 | 预估路况和客流 |
| ProfiledDB | 保存历史执行性能数据的数据库 | 历史出行耗时记录 |
开发和部署流程大致是:先通过 JavaPlanBuilder 或 Scala API 构建 WayangPlan;接着 Optimizer 对 WayangPlan 做剖析和优化,为每个算子挑选候选平台实现;然后根据估算的数据量和历史性能数据计算代价,选出整体代价最低的执行计划;最后把计划分发到各个 Platform 执行。
2.3 和 Spark / Flink / Trino / Calcite 到底什么关系
这句话我经常要和人解释:Wayang 不是 Spark 和 Flink 的替代品,而是它们的“上级调度层”。Spark 和 Flink 是实际干活的引擎,Wayang 自己几乎没有计算能力,除非你只注册了本地方案。它更像是一个元引擎或者引擎编排层。
和 Trino 相比,Wayang 不是“把多源数据拉过来统一查询”,而是“把一组数据处理算子分发给最合适的引擎,让引擎用自己的能力去算”。它更强调整体数据处理管道,而不是单一 SQL 查询。和 Calcite 相比,Wayang 的抽象层次更靠近“工作流”层面,对运行时引擎的选择更直接。Wayang 甚至可以和 Calcite 这类工具互补——比如你在 Wayang 之上接一层 SQL 解析器,解析后的逻辑计划可以用 Wayang 分发到不同引擎执行。
3. 一次跨引擎执行背后的完整链路
3.1 构建 WayangPlan:平台无关的逻辑计划
理解 Wayang 的钥匙,是把“逻辑计划”和“物理执行计划”分开看。你在代码里写的 filter、map、reduceByKey,最初只是构建了一个逻辑计划,这个计划里的算子不绑定任何引擎。举个例子,你写了一个 filter(s -> s.length() > 4),Wayang 此时并不知道这个 filter 会跑在 Java 流上还是 Spark RDD 上,它只知道这是 WayangPlan 中的一个 Filter 算子。
这种设计在数据库系统里很常见——SQL 先转成逻辑计划,再转成物理计划。Wayang 把这一套思路推广到了跨引擎场景。逻辑计划阶段的优点是:业务代码只关心“要做什么”,不关心“怎么做”,后续无论底层引擎怎么换,这层逻辑都不受影响。
构建逻辑计划最常用的入口是 JavaPlanBuilder。它和 Spark 的 Dataset API 风格相似,所以从 Spark 迁移过来的同学会很快上手。比如 loadCollection、readTextFile、filter、map、flatMap、join、count 这些方法,都能把算子逐步累加进 WayangPlan。
3.2 优化器枚举候选实现:同一个算子,多种平台实现
Wayang 优化器最核心的工作,是“算子实现选择”。每一个逻辑算子,在不同平台上都有对应的物理实现。过滤这个操作,在 JavaStream 上是一个 stream.filter,在 Spark 上是一个 RDD.filter,在 PostgreSQL 上可以是 WHERE 子句,在 Flink 上则是 DataStream.filter。Wayang 会为每个算子枚举出所有可用平台提供的候选实现,然后从这些候选组合中找出一条最优路径。
这个枚举组合的空间会很大,所以优化器不是盲目穷举。它内部有规则集,会先做一批逻辑优化,比如合并相邻的 map/filter、裁剪不需要的列;然后再做平台实现选择,用剪枝策略缩小搜索范围。
有个很重要的点:Wayang 不仅可以为整个任务选一个平台,还可以为不同算子选不同平台。比如一个管道里,前段的过滤和下推查询可能交给 PostgreSQL,中段的复杂数据清洗交给 Spark,末段的机器学习训练交给集成了 MLlib 的平台。这种“算子级跨引擎执行”是它区别于普通路由网关的核心。
3.3 代价模型:怎么知道哪个平台更快
候选实现很多,光靠拍脑袋肯定不行。Wayang 用两个东西来估算代价:一是 CardinalityEstimator,估算每个算子输入的数据量;二是 ProfiledDB,记录历史上每种实现在不同数据量下的执行耗时、吞吐等指标。
你可以把 CardinalityEstimator 理解为“预判这个算子会处理多少行数据”,数据量是最影响平台选择的因素。一万条数据用本地 Java 流可能只要几毫秒,为了这点数据拉起一个 Spark 集群反而是浪费;但如果是几亿条数据,本地流直接内存溢出,Spark 的分布式优势就体现出来了。
ProfiledDB 记录的是历史真实执行数据。Wayang 在运行过程中会把每次执行的各种指标写进去,下次估算代价时参考历史数据。这和导航软件里的“历史路况”很像:不是靠拍脑袋说哪条路快,而是看过去同一个时间段、同一条路上实际跑了多久。代价模型综合这些信息后,给每个候选方案打一个分,最后选总分最低的组合。
3.4 执行与结果合并
确定执行计划之后,Wayang 会把算子分发给对应平台。如果整个任务只有一个平台,那它本质上和直接用那个平台没有太大区别;但如果是跨平台执行,中间数据的传递就成了关键。
Wayang 对中间结果的传递做了抽象。当一个算子输出的数据要被另一个平台上的算子消费时,数据需要跨进程甚至跨节点传输,这中间会涉及序列化和反序列化。这也是跨引擎组合的一个隐性成本:引擎衔接处的数据传输可能比算子本身还贵。所以 Wayang 的优化器在计算代价时会把“数据交换代价”也算进去,尽量避免为了炫技而拆得七零八落。
最终,每个平台执行完自己负责的那部分算子后,结果会汇总回 Wayang 的执行层,或直接交给下一个阶段继续处理。对于用户来说,你调用的 collect() 拿到的是最终结果,整个跨引擎过程被封装在了内部。
4. 十分钟跑通第一个 Wayang 程序
4.1 环境准备与 Maven 依赖
Wayang 是用 Java/Scala 写的,所以你的开发环境首先得有 JDK 8 或 11,Maven 3.6+,另外需要准备一个 Scala 版本匹配的依赖坐标。下面这份 pom.xml 以 0.7.x 版本为例,实际使用时建议去 Maven Central 搜一下最新的 release 版本,以免依赖过期。
xml复制<dependencies>
<dependency>
<groupId>org.apache.wayang</groupId>
<artifactId>wayang-core</artifactId>
<version>0.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.wayang</groupId>
<artifactId>wayang-api-scala-java_2.12</artifactId>
<version>0.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.wayang</groupId>
<artifactId>wayang-basic</artifactId>
<version>0.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.wayang</groupId>
<artifactId>wayang-platform-java-stream</artifactId>
<version>0.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.wayang</groupId>
<artifactId>wayang-platform-spark</artifactId>
<version>0.7.1</version>
</dependency>
</dependencies>
强调一下,wayang-api-scala-java 这个 artifact 带 _2.12 后缀,是因为它依赖 Scala 2.12 的某些标准库。如果你的工程里 Spark 版本对应的是 Scala 2.11,这里就要换成 _2.11,否则会有一堆冲突。
4.2 注册插件并写第一个数据处理任务
下面这段代码是我实际跑通过的一个小例子:用一个字符串列表做数据处理,过滤出长度大于 4 的单词,再转成大写,最后 collect 出来打印。
java复制import org.apache.wayang.api.JavaPlanBuilder;
import org.apache.wayang.core.api.Configuration;
import org.apache.wayang.core.api.WayangContext;
import org.apache.wayang.java.Java;
import org.apache.wayang.spark.Spark;
import java.util.Arrays;
import java.util.Collection;
public class WayangQuickstart {
public static void main(String[] args) {
Configuration configuration = new Configuration();
WayangContext context = new WayangContext(configuration)
.withPlugin(Java.basicPlugin())
.withPlugin(Spark.basicPlugin());
JavaPlanBuilder planBuilder = new JavaPlanBuilder(context, "wayang-demo")
.withUdfJarOf(WayangQuickstart.class);
Collection<String> words = Arrays.asList("spark", "flink", "wayang", "presto");
Collection<String> result = planBuilder
.loadCollection(words)
.filter(s -> s.length() > 4)
.map(String::toUpperCase)
.collect();
System.out.println(result);
}
}
这段代码有几点你需要注意:
withUdfJarOf(WayangQuickstart.class)是告诉 Wayang,你传入的 lambda/函数式逻辑来自哪个 Jar 包。后面如果要跑 Spark 等远程平台,Wayang 需要把这个 Jar 分发给集群,这个配置能避免很多 ClassNotFoundException。loadCollection在逻辑上是一个 Load 算子,优化器会根据数据量判断,是直接走 JavaStream,还是把数据转成 Spark RDD。- 这段代码在没有 Spark 环境的情况下也能跑,只要删掉
Spark.basicPlugin()注册,Wayang 就会退化为纯本地方案执行。
4.3 运行日志里到底发生了什么
跑起来之后,Wayang 会在日志里输出优化器的工作过程。我这边看到的日志大概长这样:
text复制INFO: Parsing and optimizing WayangPlan...
INFO: Propagating cardinalities...
INFO: Filter #1 estimated cardinality: 4
INFO: Map #2 estimated cardinality: 4
INFO: Selecting execution platforms...
INFO: Operator Filter #1 alternatives: [JavaStream Filter, Spark Filter]
INFO: Operator Map #2 alternatives: [JavaStream Map, Spark Map]
INFO: Estimated costs:
INFO: All-JavaStream plan: 356 ms (estimated)
INFO: All-Spark plan: 1432 ms (estimated)
INFO: Selected plan: All-JavaStream
INFO: Executing...
因为数据量只有 4 条,成本模型算出 Spark 方案要额外承担序列化和调度开销,所以最终选了 JavaStream。这时候你可能会觉得“那我注册 Spark 干嘛”?其实这正是优化器的价值:数据量小时避免为了分布式而分布式,数据量大了它会主动切过去。
4.4 没有集群也能测:只用本地方案
Wayang 最友好的地方是:你不需要一开始就搭集群。把 Spark、Flink 这些重型插件都注释掉,只保留 JavaStream 插件,你的业务代码可以像普通 Java 程序一样直接在本地跑。但这里有个陷阱——如果代码里用了高成本算子,比如大表 join,本地 Java 流会成为性能瓶颈。你需要在本地测试和生产环境之间,准备好不同的 Plugin 注册策略。
我个人的实践是:写一个 registerPlatforms() 工具方法,用配置项控制哪些 Plugin 被加载。本地默认只加载 JavaStream,测试环境加载 Spark,生产环境再根据需求加上 PostgreSQL 或 Flink。这样同一套业务代码可以在不同环境下切换执行引擎,几乎零成本。
5. 我把 Wayang 用在真实任务后踩过的坑
5.1 依赖冲突:Spark 与 Scala 版本是重灾区
Wayang 的插件依赖会把你项目里的 Spark、Hadoop 版本搅和在一起。如果你原本工程里已经有一个 Spark 2.4,而 Wayang 某个版本内置的 Spark 插件依赖 3.x,Maven 依赖树里就会有两套 Spark 类。运行时会看到各种 NoSuchMethodError 或者 IncompatibleClassChangeError。
我的建议是:在引入 Wayang 插件依赖之前,先用 mvn dependency:tree 看清楚冲突点,并把 Wayang 相关依赖的传递依赖排除掉,统一到你公司使用的 Spark 版本上。如果你决定跟随 Wayang 的默认版本,那就要把工程里原本的 Spark 依赖版本调成一致。这个事没有捷径,只能在项目初始化时花时间处理。
5.2 UDF 传不到远端平台
我最早跑通本地示例后,兴冲冲地加了一个 Spark 插件,想把同样的代码扔到集群上跑。结果发现集群上报错,说找不到我定义的过滤函数类。原因就是我没有正确配置 withUdfJarOf,Wayang 没法把我的匿名 lambda 打包给 Spark 执行器。
解决方法有两个:一是坚持调用 withUdfJarOf,保证 UDF 所在 Jar 能被上传;二是尽量避免依赖外部上下文的匿名内部类,把逻辑写进一个独立的、可序列化的类里。这个经验其实和直接用 Spark 时遇到的“Task not serializable”很类似,但 Wayang 把异常信息包装得更隐蔽,所以排查起来反而更费劲。
5.3 “自动选择”在小数据量时可能让你懵
有一次我测试一个从 JDBC 读取订单表然后做聚合的任务。Wayang 的优化器评估后,只用了 JavaStream 读全表数据,再在本地做聚合,没有把聚合下推给 PostgreSQL。原因是我传入的一个参数让基数估计器认为数据量只有几千行,所以它认为“全量拉取到本地算更快”。
但如果数据量实际上有几百万行,这个决策就是灾难。Wayang 的基数估计器不是万能的,它有时候依赖用户提供的 hints,有时候依赖历史数据。我在实践中发现,对于有明显过滤条件的 SQL 来源表,最好先自己预估一下过滤后的数据量,必要时通过配置调整估计器策略,别把“自动”两个字想得太神。
5.4 跨引擎组合不是什么场景都划算
Wayang 宣传的“一个任务拆给多个引擎”很吸引人,但它意味着数据要跨进程传输。假设你让 PostgreSQL 执行 join,然后让 Spark 执行 join 结果的聚合,PostgreSQL 产出的中间结果需要序列化并传给 Spark。如果这个中间结果很大,传输和序列化的开销可能远超 join 本身。
所以我的态度是:跨引擎拆分适合那种“每个引擎都做高价值重计算”的场景,比如图算法交给图引擎、机器学习训练交给 ML 引擎、关系型 join 留给数据库。如果只是把任务拆开然后互相喂数据,建议让优化器去决定,不要手动指定执行平台。
5.5 文档与社区的现实情况
作为 Apache 孵化项目,Wayang 的文档成熟度没法跟 Spark 这种顶级项目比。很多 API 的变化没有详细的 changelog,你需要直接去 GitHub 翻示例代码和 issue。我有一次想知道某个算子在新版里是否还支持,最后是在 issue 评论区找到了答案。
如果你打算在正式项目里用,我建议做两件事:第一,锁定版本并升级前做充分的回归测试;第二,把 Wayang 的核心调用封装在一个内部公共模块里,方便以后底层升级或替换。这一层“防腐层”能帮你隔离很多上游变动带来的风险。
6. 什么场景我推荐上 Wayang,什么场景先等等
6.1 我觉得 Wayang 很有价值的三类场景
第一类是“多引擎并存的中大型团队”。公司里既有 Spark 也有 Flink,还有一堆数据分析师直接连 PostgreSQL,Wayang 可以作为一个统一入口,把不同引擎暴露给上层应用的能力收敛成一套 API。这能显著降低多引擎给上层带来的认知负担。
第二类是“技术选型还没定,但想避免押注风险”的新平台项目。你可以在项目初期用 Wayang 接入最容易部署的引擎,后续业务明确后再动态加入更适配的引擎,业务代码不用重写。
第三类是“学术研究或引擎对比工具链”。Wayang 天然适合做实验平台,你可以用同一套测试负载,跑在不同引擎上,利用它的日志和 ProfiledDB 做性能对比。这比手工维护多套测试代码干净得多。
6.2 现阶段不建议硬上的场景
如果你们团队小,数据处理链路单一,目前只用一个 Spark 就能覆盖全部需求,那我不建议现在引入 Wayang。额外抽象层是要付出学习成本和运维成本的,Wayang 的自动优化对你们来说可能不是“省事”,而是“黑盒”。
如果业务对延迟极其敏感,需要毫秒级实时响应,Wayang 目前的能力也不是为这种场景设计的。它更适合批处理、管道式数据处理和近似交互式分析,直接拿去做高频实时风控这类任务,你会发现在引擎调度上多损耗的时间很难接受。
6.3 如果要做技术调研,我建议这样下手
先别急着写业务代码,去 GitHub 上把 apache/wayang 仓库的 examples 模块拉下来,一行行读它的 demo。重点看两个东西:一是不同数据量下优化器如何切换平台,二是同一个逻辑任务在注册不同插件组合时,产生的执行计划差异。
然后自己搭一个最小 Demo:本地数据源 + JavaStream + Spark 三个组件,把 filter、map、join、collect 都跑一遍。跑的过程中打开 Wayang 的日志,看看优化器输出了什么、代价估算值是多少。这个过程会比看十篇文档都管用。
根据我个人的使用体会,Apache Wayang 最打动我的不是它现在多成熟,而是它把一个正确的问题摆到了桌面上:数据处理不应该被某个引擎焊死。即便你最后不用它,“给未来留一条换引擎的路”这种架构思想也值得借鉴。如果你所在的团队正在为 Spark、Flink、Trino 的选型头疼,花一个下午把 Wayang 跑一遍,你一定会对“引擎中立”这四个字有完全不同的理解。
