测试平台-05-测试平台的任务调度与执行引擎

测试平台做到一定阶段后,最容易暴露问题的模块通常不是:

  • 任务列表页
  • 报告展示页
  • 配置页面

而是平台最底层、但最容易被写轻的一层:

任务调度与执行引擎。

早期做平台,往往会把执行链路写成下面这种样子:

  • 页面点一次执行
  • 后端收一个请求
  • 直接起进程或者调脚本
  • 跑完把结果回写数据库

刚开始任务量少时,这种做法看起来完全能用。

但只要平台开始承载真实使用,问题会很快冒出来:

  • 同一时间多个任务一起跑,资源抢占开始失控
  • 任务执行到一半挂掉,平台不知道它到底算失败还是还在跑
  • 执行机断开、进程退出、日志没回传,状态卡在“运行中”
  • 重试做得太粗,错误任务被重复放大
  • 同一个环境被多个任务同时占用,结果彼此干扰

所以测试平台的执行能力,本质上不是“把脚本跑起来”这么简单,而是:

把一次任务从“申请执行”推进到“可追踪地完成”,并且在异常情况下也能被正确收敛。

这篇文章就只讲这个问题:

测试平台的任务调度与执行引擎,到底该怎么设计,才不至于后面越跑越乱。

一、调度和执行,最好先分成两层看

很多平台最容易犯的一个错误,就是把调度和执行写成同一个模块。

例如:

  • 接口收到了执行请求
  • 立刻判断资源
  • 直接起任务
  • 顺手写状态
  • 顺手收日志

看起来链路短,但后面几乎一定会出问题。

更推荐一开始就把它拆成两层:

1. 调度层

调度层回答的是:

  • 哪些任务可以进入执行队列
  • 哪个任务先跑
  • 当前资源是否允许它跑
  • 是否需要排队、延迟、限流、重试

它关心的是“谁先执行、在哪里执行、什么时候执行”。

2. 执行层

执行层回答的是:

  • 任务在执行机上怎么真正跑起来
  • 执行日志、产物、状态怎么回传
  • 失败、超时、中断怎么处理

它关心的是“任务怎么跑、怎么收尾、怎么留证据”。

这两层一旦混在一起,后面最容易出现的症状就是:

  • 状态机混乱
  • 排队规则写在执行器里
  • 执行器开始感知页面逻辑
  • 重试逻辑到处散落

二、执行链路最先要明确的,不是技术栈,而是状态机

很多平台讨论执行引擎时,最容易先聊:

  • 用不用消息队列
  • 用不用 Redis
  • 用 goroutine 还是 worker pool

这些都重要,但不是第一步。

第一步应该先回答:

一条任务从创建到结束,中间到底会经历哪些状态。

如果状态机没想清楚,后面再好的技术选型都只是把混乱放大。

更常用的一套最小状态流转通常是:

  • pending:任务已创建,等待调度
  • queued:任务已进入候选队列,等待资源
  • dispatching:调度器已选中,准备分发到执行节点
  • running:执行器已真正开始运行
  • success:执行成功完成
  • failed:执行失败完成
  • timeout:超过最大执行窗口
  • canceled:被主动取消
  • lost:平台和执行现场失联,需要人工或守护程序收敛

这里最容易被漏掉的就是 dispatchinglost

因为真实平台里经常会出现下面这种中间态:

  • 平台已经把任务发出去了
  • 执行节点还没来得及确认
  • 或者节点短暂失联

如果没有中间状态,平台就很容易出现“双发”或者“假卡死”。

三、调度层真正要解决的,不只是排队,而是资源冲突

调度这件事很容易被理解成:

  • 先进先出
  • 优先级高的先跑

这当然是一部分,但测试平台里的调度难点往往不在“队列算法”本身,而在:

资源约束非常复杂。

测试平台里常见的资源不只是机器 CPU 和内存,还包括:

  • 测试环境名额
  • 真机设备
  • 浏览器节点
  • 共享账号
  • 测试数据池
  • 外部依赖配额

也就是说,一条任务能不能跑,不只是看“有没有空 worker”,还要看:

  • 它需要的环境现在是否被占用
  • 它需要的设备是否健康
  • 同类任务是否超过并发上限
  • 是否会挤占高优任务的执行窗口

所以调度层最好至少能回答 4 个问题:

1. 任务需要哪些资源

例如:

  • 环境:test-a
  • 设备:android-17
  • 执行节点标签:ui-runner
  • 并发槽位:1

2. 这些资源当前是否可用

不仅要看有没有,还要看:

  • 是否健康
  • 是否被别人租约占用
  • 是否满足版本或能力要求

3. 任务是否允许排队等待

有些任务适合排队:

  • 夜间回归
  • 非紧急冒烟

有些任务不适合排太久:

  • 提测验收
  • 发布前阻塞校验

4. 资源释放后,谁先补位

如果没有明确规则,就很容易出现:

  • 低优先级任务长期占满资源
  • 高优任务永远插不进来

四、更推荐的一套执行骨架

如果现在从 0 到 1 做一版测试平台执行链路,更推荐先把骨架收成下面几层。

1. 任务定义层

负责存:

  • 任务类型
  • 执行入口
  • 默认参数
  • 资源需求模板
  • 超时阈值

这里不存运行态。

2. 任务实例层

每次执行都生成一条独立实例,负责存:

  • 触发来源
  • 实际参数
  • 当前状态
  • 分配到的资源
  • 开始结束时间

这是平台后面做追踪、回放、审计的基础。

3. 调度器

负责:

  • pending/queued 任务里挑候选
  • 根据优先级、资源、限流规则排序
  • 为任务申请租约
  • 把任务分配给合适的执行节点

4. 执行代理

部署在执行节点上,负责:

  • 拉取任务
  • 准备运行目录
  • 拉起脚本、容器或命令
  • 持续回传心跳、日志和状态

5. 收尾与回收器

负责:

  • 超时任务终止
  • 僵尸任务清理
  • 租约释放
  • 日志补采
  • 产物归档

这块如果没有单独设计,后面“卡运行中”几乎一定会成为平台顽疾。

五、任务执行不要直接绑死“执行命令”,最好抽成标准协议

很多平台第一版执行器最常见的写法是:

  • 后端拼一个 shell 命令
  • 远端机器执行
  • 拿 exit code 当最终结果

这种做法能启动很快,但上限很低。

因为测试平台真正要关心的,不只是命令返回值,还包括:

  • 执行开始没
  • 中间跑到哪一步
  • 日志是否完整
  • 中途是否重试过
  • 产物在哪
  • 为什么失败

所以更推荐把“执行任务”抽成一套标准协议,至少包含:

  • prepare
  • start
  • heartbeat
  • append_log
  • report_artifact
  • finish

这样平台和执行器之间就不会只靠“一条命令是否退出成功”来沟通。

六、最小可执行实践:一条任务怎么从入队跑到回收

如果要把这件事做成可以直接落地的第一版,更推荐下面这个顺序。

1. 建 3 张核心表

  • task_definition
  • task_execution
  • resource_lease

其中:

  • task_definition 只存静态定义
  • task_execution 存一次次运行实例
  • resource_lease 存环境、设备、账号等资源占用

2. 先把执行入口收成统一格式

例如任务定义里至少明确:

1
2
3
4
5
6
7
8
9
10
{
"task_type": "api_regression",
"entrypoint": "pytest -m smoke",
"timeout_seconds": 1800,
"resource_requirements": {
"runner_tag": "api",
"env": "test-a",
"device": ""
}
}

这一步的价值不在字段本身,而在于后面调度器终于知道一条任务要消耗什么。

3. 调度器固定周期扫待执行实例

第一版完全没必要上来就做得很复杂。

可以先每 3-5 秒扫一次:

  • 找出 pending/queued 任务
  • 判断资源是否可满足
  • 给可执行任务分配租约
  • 把状态切到 dispatching

4. 执行代理只做一件事:可靠回传

执行器不要一开始塞太多业务逻辑。

第一版把这几件事做好就够:

  • 拉取任务
  • 起执行进程
  • 5-10 秒发一次心跳
  • 流式回传日志
  • 结束时上传产物和退出状态

5. 单独做一个超时与失联回收任务

这一步会拖到后面做,但其实应该尽早做。

它至少要能处理:

  • 超过执行阈值
  • 长时间没有心跳
  • 节点已经下线
  • 资源租约未释放

七、现场观察点:任务跑不稳时先看哪几件事

当平台执行链路开始出问题时,排查起点通常不该先盯页面,而是先看下面这张排查顺序。

1. 任务有没有真的进入正确状态

先确认:

  • 是停在 pending
  • 还是停在 queued
  • 还是卡在 dispatching
  • 还是 running 但没有心跳

不同状态,问题根因通常完全不同。

2. 资源租约有没有异常

例如:

  • 任务已经结束,但资源没释放
  • 任务没开始,却先抢占了资源
  • 一个资源被多任务同时占用

3. 执行节点是否真的接到了任务

这里要看:

  • 节点在线状态
  • 最近心跳
  • 最近拉取记录
  • 任务下发日志

4. 执行现场是否有证据

例如:

  • 运行目录是否生成
  • 标准输出是否有内容
  • 日志是否持续回传
  • 截图、报告、产物是否落盘

5. 回收器有没有正常工作

很多平台不是不会执行,而是执行完以后收不干净,导致:

  • 页面一直显示运行中
  • 下次执行拿不到资源
  • 报告和状态脱节

八、真实项目里最容易踩的 6 个坑

1. 把任务定义和任务实例混写

后果通常是一次执行把“默认配置”给污染了。

2. 没有资源租约

结果就是同一环境、同一设备被并发任务互相踩。

3. 执行状态只靠前端轮询

一旦后端没有标准状态机,前端轮询只会把混乱更快暴露出来。

4. 没有心跳机制

任务死了,平台却还以为它活着。

5. 超时后只改状态,不清现场

这种最危险,因为表面上超时结束了,实际上进程和资源还在占着。

6. 失败重试没有分层

有些失败该直接重试:

  • 节点短时失联
  • 网络抖动

有些失败根本不该自动重试:

  • 用例断言失败
  • 环境数据脏
  • 账号权限错误

九、真实案例型段落:一次“平台越来越卡”的问题最后并不是数据库慢

场景

某次平台接入夜间回归后,团队开始频繁反馈:

  • 任务提交后长时间不开始
  • 页面上很多任务一直显示运行中
  • 新任务经常提示资源不足

最开始最直观的想法是:

  • 数据库慢了
  • 队列表太大了

执行

现场先做了 4 组检查:

  • 看待执行任务分布在哪些状态
  • 查资源租约表里有没有长时间未释放的记录
  • 查执行节点最近心跳和任务拉取日志
  • 抽 5 条“运行中”任务看现场目录和进程

现象

很快发现两个异常:

  • 大量任务卡在 running,但最近心跳已经断了很久
  • 多个测试环境长期被占用,租约没有回收

进一步看执行机,发现有一批历史进程已经退出了,但平台里对应任务还没结束。

排查

继续顺着状态链路排,最后确认:

  • 执行代理在任务退出时会上报 finish
  • 但如果节点短时重连,部分 finish 回传会丢
  • 平台又没有独立的失联回收器
  • 所以任务会永久停在 running
  • 租约也因为只在 finish 时释放,跟着一起泄漏

这时候平台表面看起来像“越来越卡”,但根因并不是数据库吞吐,而是:

执行链路缺了一段异常收敛能力。

修复

最后做了 4 个动作:

  • 增加“心跳超时自动转 lost”规则
  • lost/timeout 任务补统一回收器
  • 租约释放从“只在 finish 时释放”改成“回收器兜底释放”
  • 页面把“运行中但无心跳”单独标红,避免假稳定

修完之后,平台平均排队时间明显下降,最关键的是“卡死任务”不再持续累积。

十、任务调度与执行引擎的一个判断标准

判断一个测试平台的执行链路是否成熟,通常不会先看:

  • 页面做得是否完整
  • 支持多少任务类型

更看下面几件事:

  • 任务状态是否可解释
  • 资源冲突是否可控
  • 执行失败是否可收敛
  • 证据链是否完整
  • 异常情况下平台是否还能把现场收干净

如果这些做不到,平台看起来再丰富,本质上也只是:

一个把脚本集中放到页面上的壳。

真正的测试平台,不只是“能点执行”,而是:

能让任务可靠地被安排、执行、观察、回收,并在异常时仍然保持秩序。