从 HTTP 编排到事件驱动:一次复杂扫描任务系统的架构演进

63 阅读8分钟

背景:一次上传,多个检查

我们的产品需要支持大数据产品的代码质量检查。用户上传一个版本 zip 包后,系统会生成质量检查报告;zip 内的内容会被拆分成多个“子项目”进行扫描,例如:

  • SQL 质量检查
  • 调度引擎 Excel 工作流模板检查
  • WeData 工作流检查

为了保障用户体验:用户上传完成后立即返回“任务创建成功”,解压、分析、子任务创建与执行都走异步。

典型流程是:

  1. 用户上传触发“主任务”创建
  2. 主任务解压 zip
  3. 分析版本内容,确定要创建哪些子任务
  4. 调用子任务创建 API
  5. 子任务读取共享存储中的版本文件,完成检查并更新各自任务表
  6. 子任务通过主任务 ID 关联回主任务

现实麻烦:强耦合与状态爆炸

系统落地后,很快遇到两类问题。

第一类是强耦合:每新增一种扫描类型,主任务模块就要新增策略类、接入主流程、补齐创建逻辑。即使用策略模式/反射减少改动量,主任务“必须知道子任务世界长什么样”这件事没变。

第二类是状态复杂:父子任务要同步状态,主任务需要维护:

  • 一共有多少子任务
  • 谁成功谁失败
  • 新增一种任务类型后,聚合逻辑如何调整

需求一变,状态机就要跟着变,复杂度会“跟扫描类型数量一起涨”。

复盘:问题不在“编排方式”,在责任边界

复盘后我们意识到:难点不在“用 HTTP 调接口编排”,而在于任务生命周期如何定义、任务之间的责任边界怎么划分、状态到底由谁维护

一句话概括:主任务不应该关心有多少子任务,而只关心最终状态如何收敛。

下面我展开解释为什么。


从 HTTP 编排到事件驱动:关键不是通信,而是观念

在旧架构里,子任务的创建与执行是由接口调用驱动的。接口调用天生是一种“命令”,它隐含了大量耦合前提。

1)把“命令”改成“事实”

当我们用 HTTP 去调用子服务时,本质是在说:

主服务 → 子服务
“请你现在去扫描 SQL”

这句话背后默认了很多事:

  • 我知道你存在
  • 我知道你能做什么
  • 我希望你“现在”执行
  • 我期待你给我一个结果
  • 失败了我必须感知、必须处理

这些默认前提,才是强耦合的根源;它们会被迫写进主流程代码里,形成“主任务 = 协调者 + 调度器 + 异常处理中心”。

事件驱动的核心转变是:把命令变成事实。比如:

  • ZipUploaded:zip 已经上传成功(事实)
  • MainTaskCreated:主任务记录已经创建(事实)
  • SubTaskCompleted / SubTaskFailed:某类扫描已经完成/失败(事实)

发送事实的一方,不需要知道“谁来处理”“何时处理”“怎么处理”。子任务也不需要回调主任务,它只要在关键节点发布新事实即可:

SubTaskCompleted(taskId=1234, type=SQL)

主任务只是“碰巧”在监听这些事件。

这里要强调:事件驱动首先是一种责任分离思想。就算底层还是 HTTP,只要做到“不点名、不强控、不等待特定执行者”,本质也已经在往事件模型靠近。

2)子任务负责自己的生命周期

在事件模型里,子任务应该做到“自主”:

  • 决定是否创建子任务
  • 自己持久化子任务记录
  • 自己驱动执行
  • 在关键节点发布状态事件

主任务则退回到更纯粹的角色:只消费事件、聚合状态、决策最终结果。

很多人会问:老架构里子任务也会写表,主任务也能查表,这不是一样吗?差别在于:老架构下主任务仍然“隐式地拥有世界观”,它必须知道:

  • 有哪些子任务类型
  • 每种子任务什么时候应该出现记录
  • 哪些状态代表完成/失败/部分完成
  • 子任务生命周期怎样才算“合理”

也就是说:表面上子任务自己写表,实际上子任务的生命周期仍被主流程间接规定;协调复杂度没有消失,只是换了种实现方式

3)当组合变化时,谁应该改代码?

举个真实需求:zip 包里可能同时包含多种工作流执行码。过去我们支持的组合大概是:

  1. SQL + Excel 工作流 + 单测
  2. SQL + WeData 工作流 + 单测
  3. 仅 SQL
  4. 仅 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,只做两件事:

  1. 创建主任务记录(INIT)
  2. 发布新事实:MainTaskCreated
Event: MainTaskCreated
{
  taskId: 123,
  context: {
    filePath,
    uploadTime
  }
}

第二步:子服务自行决定是否参与

以 SQL 扫描服务为例:

  • 监听:MainTaskCreated
  • 内部判断:
    • zip 内是否存在 .sql
    • 当前配置是否启用 SQL 扫描

不满足条件时:

  • 什么都不做
  • 这不是失败,也不产生“缺席错误”

满足条件时:

  1. 创建自己的子任务记录(RUNNING)
  2. 发布 SubTaskStarted
  3. 自己执行扫描
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"
}

第四步:主任务聚合并收敛最终状态

主任务服务监听:

  • SubTaskCompleted
  • SubTaskFailed

它只关心三件事:

  • 是否属于我的 taskId
  • 是否在“有效窗口期”内
  • 当前聚合状态是否可以收敛

一个示例聚合策略:

  1. 至少一个子任务成功 → PARTIAL_SUCCESS
  2. 全部失败 / 无子任务 → FAILED
  3. 全部成功 → SUCCESS
  4. 超时仍有未完成 → PARTIAL_SUCCESS

“不知道子任务总数”,怎么还能聚合?

这是事件模型最容易被质疑的一点:主任务不知道会有多少子任务,怎么判断“部分成功”还是“全部成功”?

答案是:“知道全部子任务”本身就是个错误前提

现实系统里,“子任务全集”天然不可知:

  • zip 内容是动态的
  • 扫描策略是可配置的
  • 子任务服务可能临时下线
  • 将来还会新增扫描类型

所以依赖“全集”的聚合,本质上是脆弱设计。更可靠的做法是承认不确定性,用“窗口期 + 收敛策略”完成闭环:在一个合理时间内尽量收集事实,然后给出可解释的最终结果。

主任务不追求全知全控,而追求:可结束、可解释、可追踪


几点补充

为了让这套模型在生产更稳,通常还需要补上这些约束:

  • 事件幂等:MainTaskCreatedSubTaskCompleted 可能重复投递,消费者要按 taskId+subTaskType+eventIdtaskId + subTaskType + eventId 去重
  • 子任务“自决”不等于“随意”:判断逻辑要可配置、可观测,最好把“为什么没参与”写进日志或指标
  • 窗口期要产品化:超时阈值、超时后的收敛规则要能配置,并能对用户解释
  • 可观测性:主任务聚合侧最好有“已观察到的子任务类型集合”“最后事件时间”等字段,便于排障
  • 异常通道:失败事件与不可恢复错误要区分;必要时引入死信队列/补偿重试策略

总结:这次重构的本质,是重新划分责任

从 HTTP 编排演进到事件驱动,并不是一次“技术选型升级”,而是一次系统责任边界的重划分。

  • 主任务不再显式调度子任务,只对事实做聚合与收敛
  • 子任务通过消费事件自主决定是否参与执行,并对自身生命周期负责
  • 最终状态不依赖精确掌握子任务全集,而是依赖窗口期与规则收敛

系统不再追求全知全控,而是在不确定环境下依旧能稳定结束,并输出合理结果。架构演进的价值,往往不在于引入更复杂的技术,而在于让每个组件只承担它真正该承担的责任。


如果你正面临复杂任务编排、系统解耦或事件驱动改造的问题, 我很乐意交流具体场景和设计取舍。