数据平台里的任务有很多名字:离线分析、数据汇聚、实时建模、数据集成、定时任务。它们使用的执行引擎不同,但后端需要解决的问题高度相似。
用户创建一个任务,平台生成配置并提交到外部引擎;任务可能启动失败、运行数小时、被人工停止,也可能在平台重启以后继续运行。前端还要看到状态、日志、耗时和最终结果。
这篇把任务从创建到结束的完整生命周期整理一次。
一、任务不是一次 HTTP 请求
长任务最重要的边界,是把“发起操作”和“执行完成”拆开。
HTTP 请求
└── 校验参数
└── 创建任务记录
└── 提交执行请求
└── 返回 task_id
后台流程
└── 启动外部任务
└── 轮询或接收状态
└── 更新结果
接口只对“是否成功受理”负责,不应等待整个任务执行完毕。否则浏览器超时、网关重启或客户端断开都会影响任务。
二、先定义状态机
如果状态只用 0、1、2 表示,却没有明确转换关系,后面很容易出现“已删除但仍在运行”“启动失败却显示运行中”。
一套基础状态可以是:
CREATED -> STARTING -> RUNNING -> SUCCEEDED
| ├-> FAILED
| ├-> STOPPING -> STOPPED
| └-> TIMEOUT
└-> START_FAILED
状态机要回答四个问题:
- 当前状态允许执行哪些操作;
- 谁负责推动状态变化;
- 重复收到同一个事件时如何处理;
- 平台状态与外部引擎状态冲突时相信谁。
更新状态时最好带上前置条件:
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 任务无法估算时也可以展示“排队、提交、执行、写入结果”等阶段。
六、平台重启后的恢复
服务启动时不能默认内存中的任务列表是真相。需要查询数据库中处于 STARTING、RUNNING、STOPPING 的实例,再向外部引擎确认。
对账结果通常有三种:
| 平台状态 | 引擎状态 | 处理方式 |
|---|---|---|
| RUNNING | RUNNING | 恢复监控 |
| RUNNING | SUCCEEDED | 补写成功状态 |
| RUNNING | NOT_FOUND | 标记异常并告警 |
这类恢复逻辑平时很少触发,却决定了平台重启以后是否可信。
七、删除不是一条 DELETE
删除任务前要明确删除的是什么:
- 删除任务定义,是否保留执行历史;
- 正在运行的实例是否先停止;
- 外部引擎中的任务是否同步清理;
- 日志文件和结果数据是否删除;
- 定时调度是否取消。
更稳妥的做法通常是先停用、再停止、最后软删除。真正的物理清理由后台任务延后执行。
八、检查清单
上线一个新任务类型前,我会检查:
- [ ] 状态与合法转换是否明确;
- [ ] 定义和实例是否分离;
- [ ] 提交、停止、回调是否幂等;
- [ ] 是否设置超时和最大重试次数;
- [ ] 是否保留外部任务 ID;
- [ ] 服务重启后能否恢复监控;
- [ ] 日志中是否能按
task_id检索; - [ ] 删除操作是否处理外部资源;
- [ ] 定时触发与手动触发是否可区分;
- [ ] 同一个任务是否允许并行执行。
任务调度最难的部分不是准时调用一个函数,而是让任务无论成功、失败、重启还是重复触发,都能停留在一个可以解释的状态。