上篇定了调度与血缘的握手协议,本篇是全系列工程含金量最高的一篇——解决"任务怎么定义、怎么跑"的问题)。
一、痛点场景:半年 60 个 Spark 作业,没人敢动
平台跑起来半年后,Spark 作业数量翻了三倍。每接一个数据需求的标准动作是:写一个 Main 类 → 打一个 fat jar → 上传到跳板机 → 建 DolphinScheduler 任务 → 联调 → 发版。最忙的一周发了四次版,有一次只改了一行 SQL 的 where 条件——为了这一行 SQL,走了完整的编码、打包、审批、发布流程。三个问题随之爆发。其一,任务碎片化:60 个作业就是 60 个 fat jar,依赖冲突修一次要全量回归;其二,Yarn 资源申请放大:每个作业独立 spark-submit,每个 Application 冷启动都要申请 Driver + Executor 资源,大量小任务把队列吃满,真实计算时间不到运行时间的三分之一;其三,知识沉淀为零:SQL 散落在 60 个类的字符串常量里,新人想知道"客户表每天怎么加工的",只能逐个翻代码。
二、主流方案为什么不够:三个看似合理的逃逸路线
路线一:DolphinScheduler 的 SQL/Shell 任务节点直接拼 spark-submit。把 SQL 放在调度系统的任务参数里,看似解决了"改 SQL 要发版",但 SQL 散落在调度系统的任务定义里,没有版本管理、没有参数校验、没有统一的水位和血缘上报(第 6 篇的握手协议就接不上了),等于把碎片化从代码库搬到了调度平台。路线二:上 Kyuubi / Livy 这类 Spark 服务化网关。它们解决的是"SQL 提交的服务化",但企业级采集任务的套路——增量水位、断点重跑、全量初始化、血缘上报——每个作业还是各写各的,碎片化的根子没动。路线三:自研一个"任务 DSL"。方向对了,但很多团队死在把 DSL 做成了编程语言:支持 if/else/循环,最后没人会用、没人敢改。结论:要产品化的不是提交方式,而是"任务的共性"。采集类任务 90% 的流程是相同的(取水位 → 拼增量 SQL → 读写湖表 → 上报血缘),不同的只是那 10% 的配置。把这 90% 沉淀成一个引擎,让 10% 的差异全部落在配置表里。
三、我的设计与取舍:父-子两级任务模型 + 二维正交路由
核心设计一:业务 SQL 全存配置表,代码里没有一行业务 SQL。
-- ETL v2 主任务配置表(脱敏)
CREATE TABLE t_etl_v2_main (
main_code VARCHAR(64) COMMENT '主任务编码,如 main_customer_daily',
engine_type VARCHAR(32) COMMENT '执行引擎:SPARK / TRINO',
source_db VARCHAR(64) COMMENT '源端数据库',
schedule_cron VARCHAR(32) COMMENT '调度周期',
task_status TINYINT COMMENT '0-停用 1-启用'
);
-- 子任务配置表:一个主任务挂 N 个子任务
CREATE TABLE t_etl_v2_sub (
sub_code VARCHAR(64) COMMENT '子任务编码,如 sub_ods_opportunity',
main_code VARCHAR(64) COMMENT '所属主任务',
source_table VARCHAR(128) COMMENT '源表,如 ods_sc_crm.opportunity',
target_table VARCHAR(128) COMMENT '目标表,如 iceberg.ods.ods_opportunity',
incr_field VARCHAR(64) COMMENT '增量水位字段,空则为全量',
custom_sql TEXT COMMENT 'SQL 自定义逻辑:完整 SQL 或对源表查询的注入条件',
processor VARCHAR(64) COMMENT 'Processor 扩展点,空则走 General 兜底'
);
核心设计二:父-子两级任务模型,一次调度 = 一个父任务 = N 个子任务 = 一个 Spark Application。调度系统只编排父任务;父任务启动后从配置表拉取子任务清单,在一个 Application 内串行/受控并发跑完所有子任务。60 个作业从 60 次 spark-submit 收敛为 6 个父任务,Yarn 冷启动开销直接降一个数量级,且同一主任务下的子任务天然共享一条血缘上报链路。核心设计三:引擎维度 × 任务维度的二维正交路由。引擎维度解决"用什么跑"(Spark 吃大批量、Trino 做联邦),任务维度解决"怎么跑"(全量/增量/SQL 自定义)。两个维度正交组合,新增引擎或新增任务类型都不动对方。引擎侧复用第 5 篇的 IEngineSession.identifier(),父任务上报的运行坐标对上层完全透明。为什么不选 Flink CDC / DataX 全家桶?Flink CDC 强在实时,我的场景以 T+1 批量为主,为 10% 的实时需求引入一整套流式运维不值;DataX 插件化思想很好,但它是"单表搬运"模型,SQL 自定义逻辑(多表join后入湖)和湖格式特性(Iceberg 分区、回刷)要绕路实现。取舍的本质:配置驱动引擎的复杂度守恒——省下的编码工作量,会转化为配置模型的设计成本。前期把配置模型设计对,后面全是收益。
四、关键设计:Processor 反射加载 + General 兜底
引擎骨架是"通用流程 + 可插拔扩展点":主流程固定(加载配置 → 路由 Processor → 执行 → 上报),差异点抽象成 Processor 接口,按配置反射加载,不配置则走 General 兜底:
/**
* 子任务处理器扩展点(脱敏):通用流程中的可插拔差异点
*/
public interface SubTaskProcessor {
/** 是否可处理该子任务(按 processor 配置或能力声明路由) */
boolean match(EtlSubTaskConfig config);
/** 执行:拿到引擎会话与子任务配置,内部自行完成读写 */
void execute(IEngineSession session, EtlSubTaskConfig config) throws Exception;
}
/**
* 父任务主流程(脱敏简化):一个 Application 跑完 N 个子任务
*/
public class EtlV2MainRunner {
public void run(String mainCode) {
List<EtlSubTaskConfig> subs = configService.loadEnabledSubTasks(mainCode);
for (EtlSubTaskConfig sub : subs) {
// 二维路由:任务维度选 Processor(反射加载),引擎维度已由 main 配置决定
SubTaskProcessor processor = processorRegistry.resolve(sub); // 未配置走 GeneralProcessor
try {
processor.execute(engineSession, sub);
lineageReporter.reportSuccess(sub); // 第6篇的 Push 血缘
} catch (Exception e) {
lineageReporter.reportFail(sub, e);
if (sub.isFailFast()) throw new RuntimeException(e); // 关键子任务可中断父任务
}
}
}
}
/** General 兜底:覆盖 90% 的标准采集(增量水位拼接逻辑第 8 篇展开) */
public class GeneralProcessor implements SubTaskProcessor {
public boolean match(EtlSubTaskConfig config) { return true; }
public void execute(IEngineSession session, EtlSubTaskConfig config) {
String sql = config.buildExecSql(); // custom_sql + 水位条件 + 权限/方言处理
session.execute(sql);
}
}
特殊需求(如某张表要先脱敏再入湖)只需写一个 DesensitizeProcessor 并在子任务配置里填上类名,主流程一行不改——这就是"改任务不改代码、不发版"的落地形态。
五、踩坑与效果
坑一:早期版本把 Processor 做成了 SPI 全量扫描。每个子任务都扫一遍 jar 里的实现类,主任务挂 40 个子任务时启动明显变慢。改为启动时一次性注册到 Map,路由 O(1)。坑二:子任务并发跑出脏数据。曾为提速让同主任务下子任务全并发,结果两个子任务写同一张 Iceberg 目标表的不同分区时触发并发提交冲突。修正:默认串行,仅对"目标表互不相同"的子任务开放受控并发(提交前按 target_table 分组校验)。坑三:配置改错了没有后悔药。有人手滑把增量条件改错,静默跑了三天错数据。补救:配置表变更走统一入口并留版本快照,执行时记录本次生效的配置版本号,出问题可一键定位到"哪次改的配置"。效果:Spark 作业从 60+ 收敛到 6 个父任务,Yarn 资源申请次数降为原来的 1/10;新接一个常规采集需求从"半天编码发版"变成"10 分钟配表 + 单子任务试跑";SQL 全部沉淀在配置表里,OpenMetadata 血缘覆盖率 100% 且免维护;团队新人上手成本从"读懂 60 个 jar"变成"读懂一张配置表"。
六、一句话总结
一句话总结:配置驱动引擎的本质是把任务的 90% 共性沉淀为流程、把 10% 差异性沉淀为配置——父-子任务模型收敛资源与编排,二维正交路由隔离"用什么跑"与"怎么跑",Processor 反射加载 + General 兜底让扩展点永远只在配置里生长;从此,改任务不改代码,不发版。