背景:一次上传,多个检查
我们的产品需要支持大数据产品的代码质量检查。用户上传一个版本 zip 包后,系统会生成质量检查报告;zip 内的内容会被拆分成多个“子项目”进行扫描,例如:
- SQL 质量检查
- 调度引擎 Excel 工作流模板检查
- WeData 工作流检查
为了保障用户体验:用户上传完成后立即返回“任务创建成功”,解压、分析、子任务创建与执行都走异步。
典型流程是:
- 用户上传触发“主任务”创建
- 主任务解压 zip
- 分析版本内容,确定要创建哪些子任务
- 调用子任务创建 API
- 子任务读取共享存储中的版本文件,完成检查并更新各自任务表
- 子任务通过主任务 ID 关联回主任务
现实麻烦:强耦合与状态爆炸
系统落地后,很快遇到两类问题。
第一类是强耦合:每新增一种扫描类型,主任务模块就要新增策略类、接入主流程、补齐创建逻辑。即使用策略模式/反射减少改动量,主任务“必须知道子任务世界长什么样”这件事没变。
第二类是状态复杂:父子任务要同步状态,主任务需要维护:
- 一共有多少子任务
- 谁成功谁失败
- 新增一种任务类型后,聚合逻辑如何调整
需求一变,状态机就要跟着变,复杂度会“跟扫描类型数量一起涨”。
复盘:问题不在“编排方式”,在责任边界
复盘后我们意识到:难点不在“用 HTTP 调接口编排”,而在于任务生命周期如何定义、任务之间的责任边界怎么划分、状态到底由谁维护。
一句话概括:主任务不应该关心有多少子任务,而只关心最终状态如何收敛。
下面我展开解释为什么。
从 HTTP 编排到事件驱动:关键不是通信,而是观念
在旧架构里,子任务的创建与执行是由接口调用驱动的。接口调用天生是一种“命令”,它隐含了大量耦合前提。
1)把“命令”改成“事实”
当我们用 HTTP 去调用子服务时,本质是在说:
主服务 → 子服务
“请你现在去扫描 SQL”
这句话背后默认了很多事:
- 我知道你存在
- 我知道你能做什么
- 我希望你“现在”执行
- 我期待你给我一个结果
- 失败了我必须感知、必须处理
这些默认前提,才是强耦合的根源;它们会被迫写进主流程代码里,形成“主任务 = 协调者 + 调度器 + 异常处理中心”。
事件驱动的核心转变是:把命令变成事实。比如:
ZipUploaded:zip 已经上传成功(事实)MainTaskCreated:主任务记录已经创建(事实)SubTaskCompleted/SubTaskFailed:某类扫描已经完成/失败(事实)
发送事实的一方,不需要知道“谁来处理”“何时处理”“怎么处理”。子任务也不需要回调主任务,它只要在关键节点发布新事实即可:
SubTaskCompleted(taskId=1234, type=SQL)
主任务只是“碰巧”在监听这些事件。
这里要强调:事件驱动首先是一种责任分离思想。就算底层还是 HTTP,只要做到“不点名、不强控、不等待特定执行者”,本质也已经在往事件模型靠近。
2)子任务负责自己的生命周期
在事件模型里,子任务应该做到“自主”:
- 决定是否创建子任务
- 自己持久化子任务记录
- 自己驱动执行
- 在关键节点发布状态事件
主任务则退回到更纯粹的角色:只消费事件、聚合状态、决策最终结果。
很多人会问:老架构里子任务也会写表,主任务也能查表,这不是一样吗?差别在于:老架构下主任务仍然“隐式地拥有世界观”,它必须知道:
- 有哪些子任务类型
- 每种子任务什么时候应该出现记录
- 哪些状态代表完成/失败/部分完成
- 子任务生命周期怎样才算“合理”
也就是说:表面上子任务自己写表,实际上子任务的生命周期仍被主流程间接规定;协调复杂度没有消失,只是换了种实现方式。
3)当组合变化时,谁应该改代码?
举个真实需求:zip 包里可能同时包含多种工作流执行码。过去我们支持的组合大概是:
- SQL + Excel 工作流 + 单测
- SQL + WeData 工作流 + 单测
- 仅 SQL
- 仅 Excel/仅 WeData + 单测
在旧模型里,哪怕做了配置化组合,主任务仍要:
- 新增判断逻辑(否则它不知道该拼哪些子任务)
- 调整状态聚合与异常处理(否则它无法收敛最终状态)
在新模型里:
- 主任务一行都不用改
- 子任务只需要改一处判断:“我是否要参与?”
这就是“自主创建”的价值:复杂度确实会下沉到子任务,但复杂度应该靠近变化源。扫描类型是变化源,因此复杂度下沉后,系统整体反而更简单、更抗变。
最终架构:主任务聚合,子任务自决
场景与角色
用户上传 zip 触发一次质量门禁,存在多类扫描:SQL / Excel / API。
- 主任务服务(Coordinator / Aggregator)
- 负责:主任务生命周期、最终状态收敛
- 不负责:具体扫描实现
- SQL 扫描服务(Python)
- Excel 扫描服务(Java)
- API 扫描服务(Java)
- 消息通道(逻辑上事件总线;物理上 MQ/HTTP/轮询均可)
事件流:四步走
第一步:Zip 上传完成(事实)
Event: ZipUploaded
{
taskId: 123,
filePath: "/upload/xxx.zip"
}
主任务服务消费 ZipUploaded,只做两件事:
- 创建主任务记录(INIT)
- 发布新事实:
MainTaskCreated
Event: MainTaskCreated
{
taskId: 123,
context: {
filePath,
uploadTime
}
}
第二步:子服务自行决定是否参与
以 SQL 扫描服务为例:
- 监听:
MainTaskCreated - 内部判断:
- zip 内是否存在
.sql - 当前配置是否启用 SQL 扫描
- zip 内是否存在
不满足条件时:
- 什么都不做
- 这不是失败,也不产生“缺席错误”
满足条件时:
- 创建自己的子任务记录(RUNNING)
- 发布
SubTaskStarted - 自己执行扫描
Event: SubTaskStarted
{
taskId: 123,
subTaskType: "SQL"
}
Excel / WeData / API 扫描服务同理:没有统一调度点,也没有人统计“总共有几个子任务”。
第三步:子任务完成或失败,发布事实
Event: SubTaskCompleted
{
taskId: 123,
subTaskType: "SQL",
result: "PASS"
}
失败则:
Event: SubTaskFailed
{
taskId: 123,
subTaskType: "SQL",
reason: "lint error"
}
第四步:主任务聚合并收敛最终状态
主任务服务监听:
SubTaskCompletedSubTaskFailed
它只关心三件事:
- 是否属于我的
taskId - 是否在“有效窗口期”内
- 当前聚合状态是否可以收敛
一个示例聚合策略:
- 至少一个子任务成功 → PARTIAL_SUCCESS
- 全部失败 / 无子任务 → FAILED
- 全部成功 → SUCCESS
- 超时仍有未完成 → PARTIAL_SUCCESS
“不知道子任务总数”,怎么还能聚合?
这是事件模型最容易被质疑的一点:主任务不知道会有多少子任务,怎么判断“部分成功”还是“全部成功”?
答案是:“知道全部子任务”本身就是个错误前提。
现实系统里,“子任务全集”天然不可知:
- zip 内容是动态的
- 扫描策略是可配置的
- 子任务服务可能临时下线
- 将来还会新增扫描类型
所以依赖“全集”的聚合,本质上是脆弱设计。更可靠的做法是承认不确定性,用“窗口期 + 收敛策略”完成闭环:在一个合理时间内尽量收集事实,然后给出可解释的最终结果。
主任务不追求全知全控,而追求:可结束、可解释、可追踪。
几点补充
为了让这套模型在生产更稳,通常还需要补上这些约束:
- 事件幂等:
MainTaskCreated、SubTaskCompleted可能重复投递,消费者要按 去重 - 子任务“自决”不等于“随意”:判断逻辑要可配置、可观测,最好把“为什么没参与”写进日志或指标
- 窗口期要产品化:超时阈值、超时后的收敛规则要能配置,并能对用户解释
- 可观测性:主任务聚合侧最好有“已观察到的子任务类型集合”“最后事件时间”等字段,便于排障
- 异常通道:失败事件与不可恢复错误要区分;必要时引入死信队列/补偿重试策略
总结:这次重构的本质,是重新划分责任
从 HTTP 编排演进到事件驱动,并不是一次“技术选型升级”,而是一次系统责任边界的重划分。
- 主任务不再显式调度子任务,只对事实做聚合与收敛
- 子任务通过消费事件自主决定是否参与执行,并对自身生命周期负责
- 最终状态不依赖精确掌握子任务全集,而是依赖窗口期与规则收敛
系统不再追求全知全控,而是在不确定环境下依旧能稳定结束,并输出合理结果。架构演进的价值,往往不在于引入更复杂的技术,而在于让每个组件只承担它真正该承担的责任。
如果你正面临复杂任务编排、系统解耦或事件驱动改造的问题, 我很乐意交流具体场景和设计取舍。