数据平台里的任务有很多名字:离线分析、数据汇聚、实时建模、数据集成、定时任务。它们使用的执行引擎不同,但后端需要解决的问题高度相似。

用户创建一个任务,平台生成配置并提交到外部引擎;任务可能启动失败、运行数小时、被人工停止,也可能在平台重启以后继续运行。前端还要看到状态、日志、耗时和最终结果。

这篇把任务从创建到结束的完整生命周期整理一次。

一、任务不是一次 HTTP 请求

长任务最重要的边界,是把“发起操作”和“执行完成”拆开。

HTTP 请求
└── 校验参数
└── 创建任务记录
└── 提交执行请求
└── 返回 task_id

后台流程
└── 启动外部任务
└── 轮询或接收状态
└── 更新结果

接口只对“是否成功受理”负责,不应等待整个任务执行完毕。否则浏览器超时、网关重启或客户端断开都会影响任务。

二、先定义状态机

如果状态只用 012 表示,却没有明确转换关系,后面很容易出现“已删除但仍在运行”“启动失败却显示运行中”。

一套基础状态可以是:

CREATED -> STARTING -> RUNNING -> SUCCEEDED
| ├-> FAILED
| ├-> STOPPING -> STOPPED
| └-> TIMEOUT
└-> START_FAILED

状态机要回答四个问题:

  1. 当前状态允许执行哪些操作;
  2. 谁负责推动状态变化;
  3. 重复收到同一个事件时如何处理;
  4. 平台状态与外部引擎状态冲突时相信谁。

更新状态时最好带上前置条件:

UPDATE task
SET status = 'RUNNING', updated_at = NOW()
WHERE id = ? AND status = 'STARTING';

受影响行数为零,说明状态已经变化,调用方需要重新读取,而不是无条件覆盖。

三、任务记录与执行实例要分开

“每日同步用户表”是任务定义;今天凌晨执行的这一轮,是任务实例。

task_definition
- id
- name
- config
- schedule
- enabled

task_instance
- id
- task_id
- trigger_type
- engine_job_id
- status
- started_at
- finished_at
- error_message

拆开以后,同一个定时任务可以保留多次执行历史,手动补跑也不会覆盖上一轮结果。

四、幂等比重试更重要

后台任务一定会遇到重试。网络超时时,我们无法确定请求没有到达执行引擎,因此“再调用一次”可能创建两个任务。

常见做法是为每次执行生成稳定的请求标识:

idempotency_key = task_id + schedule_time

执行服务先按该标识查询,存在则返回原实例,不存在才创建。停止、删除、回调处理同样应该允许重复调用。

重试解决偶发失败,幂等解决重试带来的重复副作用,两者不能互相替代。

五、进度、日志与心跳

只有“运行中”三个字,用户无法判断任务是否卡住。

任务至少应暴露:

  • 当前阶段;
  • 已处理数量与总量;
  • 最近心跳时间;
  • 可检索的执行日志;
  • 最后一次错误;
  • 外部引擎任务 ID。

进度不一定精确,但必须可解释。例如数据同步可以按已完成分片计算,SQL 任务无法估算时也可以展示“排队、提交、执行、写入结果”等阶段。

六、平台重启后的恢复

服务启动时不能默认内存中的任务列表是真相。需要查询数据库中处于 STARTINGRUNNINGSTOPPING 的实例,再向外部引擎确认。

对账结果通常有三种:

平台状态 引擎状态 处理方式
RUNNING RUNNING 恢复监控
RUNNING SUCCEEDED 补写成功状态
RUNNING NOT_FOUND 标记异常并告警

这类恢复逻辑平时很少触发,却决定了平台重启以后是否可信。

七、删除不是一条 DELETE

删除任务前要明确删除的是什么:

  • 删除任务定义,是否保留执行历史;
  • 正在运行的实例是否先停止;
  • 外部引擎中的任务是否同步清理;
  • 日志文件和结果数据是否删除;
  • 定时调度是否取消。

更稳妥的做法通常是先停用、再停止、最后软删除。真正的物理清理由后台任务延后执行。

八、检查清单

上线一个新任务类型前,我会检查:

  • [ ] 状态与合法转换是否明确;
  • [ ] 定义和实例是否分离;
  • [ ] 提交、停止、回调是否幂等;
  • [ ] 是否设置超时和最大重试次数;
  • [ ] 是否保留外部任务 ID;
  • [ ] 服务重启后能否恢复监控;
  • [ ] 日志中是否能按 task_id 检索;
  • [ ] 删除操作是否处理外部资源;
  • [ ] 定时触发与手动触发是否可区分;
  • [ ] 同一个任务是否允许并行执行。

任务调度最难的部分不是准时调用一个函数,而是让任务无论成功、失败、重启还是重复触发,都能停留在一个可以解释的状态。