一项计算任务执行了二十分钟,进度已经显示 100%,随后服务突然重启。
任务究竟算不算完成?
答案取决于几件事:
- 计算是否真的结束;
- 结果是否完整保存;
- 数据库是否已经记录结果引用;
- 旧计算进程是否仍在运行。
进度条只能表达其中一部分事实。
这类问题广泛存在于:
- 研究计算;
- 数据处理;
- 模型训练;
- 批量导出;
- 报告生成。
它们共同要求后端明确界定:
什么叫成功、什么叫失败、什么叫取消、什么叫恢复。
而且这些状态必须对应可以检查的事实。
本文介绍一种适合本地工具和中小规模单机服务的设计思路:
以 Python、关系数据库、文件产物和独立计算进程 为基础,通过分步发布、资源清理确认、多格式一致性校验和显式计算规则,构建可信的长任务流程。
这里讨论的创新价值,主要来自成熟技术的组合方式及其形成的产品行为,不以组件新颖程度衡量,也不宣称行业首创。
一、先给技术栈分配清楚的职责
一种可行的技术组合如下。
具体库可以替换,但职责边界应当保留。
| 层次 | 可采用的技术 | 主要责任 |
|---|---|---|
| 接口层 | Python、FastAPI、Uvicorn | 接收请求、提供查询、传递取消意图、输出状态事件 |
| 契约层 | Pydantic、OpenAPI | 验证输入与消息结构,描述接口,约束版本 |
| 控制层 | Python 服务模块、状态机、监督线程 | 接纳任务、分配执行位、启动计算、裁定最终状态 |
| 计算层 | 独立 Python 子进程、业务计算库 | 读取冻结输入、执行计算、生成暂存产物 |
| 事务层 | SQLite 与 APSW,或合适的数据库驱动 | 保存任务、幂等请求、执行归属、结果引用和业务记录 |
| 产物层 | JSON、PyArrow、Parquet | 保存完整结果及用于分析的结构化表 |
| 数值层 | Decimal、整数或明确约定的浮点实现 | 执行与业务要求匹配的精度、舍入和比较规则 |
| 工程层 | uv、构建工具、pytest | 固定依赖、打包交付、验证正常与故障路径 |
浏览器负责观察,持久化状态负责确认
浏览器可以:
- 通过 HTTP 查询任务;
- 通过 SSE 接收状态更新。
SSE 的价值在于让页面及时刷新。
但需要明确:
状态事件是通知机制,持久化记录才是重新连接后确认任务状态的依据。
浏览器断线、刷新或服务重启后,不能只依赖之前收到的推送事件判断任务最终结果。
为什么计算适合使用独立进程
计算放在独立 Python 子进程中,可以带来几个好处:
- CPU 工作与接口服务分离;
- 能够独立观察计算进程退出;
- 更容易实施超时;
- 更容易执行终止;
- 计算异常不必直接拖垮接口进程。
但需要注意:
普通子进程不等于安全沙箱。
如果系统需要执行不可信代码,还应额外设计:
- 权限隔离;
- 容器;
- 用户隔离;
- 文件系统限制;
- 网络限制;
- 或其他更强的执行环境。
SQLite 适合什么场景
SQLite 很适合这种单机起点。
一种常见方式是:
写请求
↓
专用写线程
↓
SQLite / WAL
只读查询
↓
独立只读连接
专用写线程可以集中事务顺序,只读连接负责查询,WAL 有助于提高读写并行能力。
但 SQLite 仍然存在:
- 写入吞吐限制;
- 单机部署假设;
- 跨节点协调能力有限。
因此:
适合作为单机长任务系统的起点,不应直接推广成多节点调度架构。
二、让“完成”对应一条结果发布协议
长任务通常会同时操作:
- 数据库;
- 文件系统。
数据库保存状态,文件系统保存:
- 模型;
- 报表;
- 分析结果;
- 导出内容。
问题在于:
数据库事务和文件系统操作之间,没有天然统一的事务。
因此,“计算完成”不能直接等价于“任务成功”。
一条更可靠的交付顺序
可以采用如下流程:
接纳请求并持久化任务
↓
冻结输入
↓
启动 Worker
↓
写入暂存产物
↓
确认 Worker 清理
↓
校验并发布产物
↓
提交结果引用
↓
标记任务完成
也可以概括为:
接纳请求并持久化任务 → 冻结输入 → 启动 Worker → 写入暂存产物 → 确认 Worker 清理 → 校验并发布产物 → 提交结果引用 → 标记完成
关键原则是:
Worker 只负责报告“产物已准备”,控制层负责决定“是否可以发布为成功结果”。
数据库中的最终成功状态,也应由控制层提交。
三、暂存产物要能证明“它属于这次任务”
Worker 写出的暂存结果,不应该只是几个散落文件。
可以为每次任务生成一份产物清单,例如:
{
"task_id": "...",
"attempt_id": "...",
"input_digest": "...",
"format_version": 3,
"files": [
{
"name": "result.json",
"size": 123456,
"digest": "..."
},
{
"name": "metrics.parquet",
"size": 456789,
"digest": "..."
}
]
}
清单至少可以描述:
- 必要文件;
- 文件大小;
- 内容摘要;
- 输入身份;
- 任务身份;
- 执行尝试身份;
- 格式版本。
发布前,控制层不仅要检查:
文件有没有写出来?
还需要检查:
这些文件是不是本次任务、本次执行产生的结果?
四、不同失败窗口,要有不同处理
长任务最重要的不是“永远不失败”,而是:
失败发生在不同位置时,系统仍然知道现在有哪些事实成立。
| 失败位置 | 可以确认的事实 | 处理原则 |
|---|---|---|
| 计算过程中退出 | 任务没有交付完整结果 | 记录失败或中断,并检查执行资源是否释放 |
| 写暂存文件时退出 | 可能存在部分产物 | 不作为成功结果开放读取,保留诊断信息 |
| 文件已发布,数据库未提交 | 文件存在,但没有成功引用 | 识别为待核对产物,不凭文件存在自动补成成功 |
| 数据库提交后响应丢失 | 服务可能已经完成交付 | 通过幂等键或任务 ID 查询原结果 |
| 成功结果后来损坏 | 历史上完成,但当前无法读取 | 保留原完成事实,另报当前不可用及原因 |
这里有一个重要区别:
“历史上已经成功”与“现在结果仍然可读取”,是两个不同事实。
例如,一个任务昨天确实成功完成,但今天磁盘损坏了。
系统不应该把历史任务状态改写成“从未成功”,而应该表达:
任务历史状态:完成
当前结果状态:不可读取
原因:产物完整性校验失败
五、文件发布也有自己的边界
文件系统操作看似简单,但其语义依赖:
- 操作系统;
- 文件系统;
- 存储设备;
- 是否跨设备;
- 是否使用对象存储。
例如:
同一文件系统中的原子重命名,不能被理解成跨设备移动也拥有同样保证。
如果使用对象存储,也不能机械照搬本地文件系统的“rename 发布”模型,而应使用其支持的:
- 条件写入;
- 版本化;
- 对象复制;
- 清单提交;
- 或其他发布协议。
因此,这套设计真正依赖的不是某一个具体 API,而是一个原则:
结果只有在完成校验并经过明确发布动作之后,才成为可引用结果。
六、把任务状态、执行身份和资源清理分开
“用户已经取消”与“计算进程已经停止”不是同一个事实。
例如:
用户点击取消
↓
取消意图已记录
↓
Worker 收到终止通知
↓
Worker 可能还在清理
↓
进程真正退出
↓
执行资源确认释放
因此,接口收到取消请求后,可以先持久化:
取消意图
随后监督器:
- 通知 Worker;
- 必要时升级终止措施;
- 检查受管理进程是否退出;
- 确认相关资源是否释放。
只有满足清理条件,才应该释放对应执行资源。
七、业务状态与清理状态应该允许同时存在
任务模型可以分别保存:
- 业务状态;
- 运行阶段;
- 清理确认;
- 当前执行身份。
例如:
业务状态:FAILED
运行阶段:CLEANING_UP
资源释放:false
一个任务已经失败,但仍在等待子进程退出,这完全是合法状态。
这种设计短期内可能降低可用执行位,但好处是:
不会为了快速恢复容量,让两个任务同时争用同一组受保护资源。
八、执行身份不要只依赖任务 ID
任务 ID 只表示:
这是哪一个业务任务。
但同一个任务可能经历多次执行尝试。
因此,一次实际运行可以同时拥有:
task_idservice_instance_idsession_idattempt_id- 随机执行标识
例如:
task_id = T1001
attempt_id = A3
session_id = S9f8...
service_instance_id = I2026...
控制层收到 Worker 消息后,应核对:
- 任务身份;
- 执行尝试;
- 会话身份;
- 消息顺序。
这样可以避免:
旧 Worker 的迟到消息覆盖新一次执行的状态。
九、服务重启后,不要只相信历史 PID
服务重启后,需要把:
- 数据库里的任务状态;
- 当前系统中真实存在的执行事实;
重新对照。
一个常见危险做法是:
读取数据库中的旧 PID,然后直接发送终止信号。
问题在于:
操作系统可能已经把这个 PID 分配给其他进程。
因此,进程生命周期管理应结合更多执行身份和启动上下文,而不是只依赖 PID。
进程组可以帮助管理子进程树,但也需要明确:
进程组是一种生命周期管理手段,不是对恶意子进程的完整安全约束。
十、幂等接纳要发生在再次启动计算之前
长任务接口很容易遇到重复提交:
- 浏览器超时后重试;
- 用户连续点击;
- 网络代理重放;
- 客户端没有收到响应。
因此,提交请求需要自己的身份。
一种常见做法是:
Idempotency-Key
+
规范化请求内容
系统应保证:
相同键 + 相同请求
返回原任务记录。
相同键 + 不同请求
返回冲突。
例如:
key = abc123
request 1:
{"symbol":"AAPL","days":30}
request 2:
{"symbol":"AAPL","days":60}
如果两次使用相同幂等键,第二次应报告:
同一请求身份对应了不同内容。
而不是重新创建任务。
数据库约束是最后一道防线
重放查询应发生在再次启动计算之前,并通过数据库唯一约束,防止并发请求同时接纳同一个幂等请求。
需要注意:
幂等接纳只减少重复调度,并不能自动保证所有外部副作用恰好发生一次。
如果任务还会调用:
- 支付接口;
- 邮件发送;
- 外部写 API;
- 云存储写操作;
这些副作用仍然需要自己的:
- 幂等键;
- 去重机制;
- 恢复规则。
十一、根据消息用途设计有界通信
长任务通常会产生多种消息:
- 进度;
- 心跳;
- 诊断;
- 日志;
- 终态。
这些消息的价值并不相同。
进度:通常只需要最新值
例如:
10%
11%
12%
...
73%
74%
页面一般只需要:
当前进度:74%
因此,进度适合采用:
最新值覆盖旧值
不一定要排队保留每一次变化。
心跳:表达“仍然活着”
心跳可以合并成一个待发送信号,例如:
last_heartbeat_at = ...
不必无限积压历史心跳。
终态:必须优先
一旦任务进入:
- 成功;
- 失败;
- 取消;
这样的最终状态,就应该:
- 优先传递;
- 停止继续产生普通进度;
- 尽快持久化。
十二、有界队列比无限队列更可信
消息通道如果无限增长,很容易出现:
生产速度 > 消费速度
↓
消息持续积压
↓
内存持续上涨
↓
服务最终异常
因此,无论使用:
- 受锁保护的槽位;
- 有界队列;
- socket;
- pipe;
都应该明确:
- 队列满怎么办;
- 发送超时怎么办;
- 通道关闭怎么办;
- 终态能否优先进入;
- 哪些消息可以覆盖。
这不是单纯的性能优化,而是任务语义的一部分。
十三、通信协议本身也要有边界
Worker 与控制层之间的消息不应该被当成“内部就一定可信”。
通信协议至少可以校验:
- 帧长度;
- 消息类型;
- 必需字段;
- 会话身份;
- 执行身份;
- 消息顺序;
- 格式版本。
例如:
{
"type": "progress",
"task_id": "T1001",
"attempt_id": "A3",
"seq": 42,
"progress": 0.74
}
控制层收到消息后,应确认:
这条消息是不是来自当前合法执行?
而不是仅仅看到 task_id 相同就接受。
十四、心跳不等于有效进展
一个 Worker 可以持续发送心跳,却卡在同一个地方几个小时。
因此:
心跳只能证明通信一侧仍有活动,不能证明计算取得有效进展。
长任务通常还需要:
- 整体截止时间;
- 阶段截止时间;
- 必要的进度观察;
- 卡死检测;
- 用户取消机制。
例如:
最后心跳:5 秒前
进度:27%
进度 40 分钟未变化
这说明:
进程可能仍然活着,但任务并没有取得可接受的进展。
十五、可以覆盖进度,不代表可以丢弃业务事件
进度和业务事实不能使用同一种保留策略。
可以覆盖:
- 进度
63%; - 上一次心跳;
- 某些临时诊断信息。
但不能轻易覆盖:
- 成交记录;
- 账本变动;
- 状态转换;
- 关键决定;
- 用户操作;
- 结果发布记录。
这些需要审计的事件,应单独持久化。
根据消息语义决定保留方式,是长任务通信设计的核心。
十六、让多格式存储与计算规则共同保证可核对性
同一份结果往往存在多种表示:
- 完整 JSON:用于恢复和追溯;
- Parquet:用于列式分析;
- 页面摘要:用于展示;
- CSV:用于用户下载。
格式越多,转换路径越多。
转换路径越多,出现不一致的机会也越多。
因此,应指定:
一个权威结果表示。
其他格式从权威表示按照确定规则生成。
十七、文件摘要与内容校验解决不同问题
例如,系统可以保存:
result.json
metrics.parquet
可以对文件计算摘要,用来回答:
文件字节有没有变化?
但如果要回答:
Parquet 中的业务内容是否正确表达了 JSON 中的结果?
仅靠字节摘要是不够的。
一种更可靠的方式是:
权威业务结果
↓
重新生成预期 Arrow 表
↓
读取实际 Parquet
↓
比较字段、类型、行数、元数据和内容
因此:
- 文件摘要用于检测字节变化;
- 内容对照用于检查业务表达。
两者解决的问题不同。
十八、大结果不一定适合一次性全量校验
全量对照会带来:
- CPU 成本;
- 内存成本;
- I/O 成本。
结果很大时,可以使用:
- 分区校验;
- 分批校验;
- 带身份的分块清单;
- 增量校验。
如果采用抽样,也需要明确:
抽样只能提供局部证据,不能等同于全量一致性证明。
另外,Parquet 等列式文件的:
- 压缩方式;
- 编码策略;
- writer 版本;
都可能变化。
因此:
业务语义相同,不一定意味着文件字节完全相同。
十九、数值规则也要成为正式契约
不同业务数值适合不同表示。
例如:
| 数值类型 | 常见选择 |
|---|---|
| 金额 | 整数最小货币单位,或 Decimal |
| 数量 | 整数 |
| 比率 | Decimal 或明确约定浮点 |
| 科学计算 | 浮点库 + 明确误差容限 |
| 百分比展示 | 基于权威数值派生 |
关键不是“全部用某一种类型”,而是:
规则需要明确、稳定、可复查。
使用 Decimal 时要固定上下文
可以对关键运算建立局部上下文:
with localcontext() as ctx:
ctx.prec = ...
ctx.rounding = ...
这样可以避免调用方的全局 Decimal 设置改变业务结果。
同时,可以区分:
- 应当完全精确的运算;
- 允许按照业务规则舍入的运算。
对于允许舍入的计算,可以保存:
原始值
舍入后值
小数位
舍入规则
是否发生舍入
规则版本
需要强调:
Decimal 也是有限精度系统,不能描述成所有数学运算都天然精确。
二十、缺失值必须带业务语义
一个指标没有值,可能有很多原因:
- 样本不足;
- 输入缺失;
- 计算失败;
- 该指标不适用;
- 尚未计算。
因此,一个指标可以不只保存:
{
"value": null
}
而是保存:
{
"value": null,
"status": "unavailable",
"reason": "insufficient_observations",
"observation_count": 3
}
这样可以保持:
“没有足够样本” ≠ “结果等于零”
后续:
- 页面;
- 导出;
- API;
- AI 解释;
都应继续保留这个语义。
二十一、这些机制提高可核对性,但不能证明公式正确
需要明确能力边界。
多格式校验、摘要和数值规则可以帮助发现:
- 输入变化;
- 转换错误;
- 舍入差异;
- 保存问题;
- 文件损坏。
但它们不能自动证明:
业务公式本身就是正确的。
公式正确性仍然需要:
- 单元测试;
- 对照案例;
- 数学验证;
- 专家审查;
- 真实数据测试。
二十二、让接口契约与前端类型来自同一个来源
接口层可以通过:
Pydantic Model
↓
OpenAPI
↓
TypeScript 类型
来减少前后端重复手写契约带来的漂移。
这样可以统一:
- 请求字段;
- 响应字段;
- 枚举;
- 可空性;
- 基础类型。
但仍然需要强调:
代码生成提升开发期一致性,不替代运行时校验和业务不变量。
例如:
end_time > start_time
这样的业务约束,即使 TypeScript 类型完全正确,也仍然需要后端检查。
二十三、AI 接入也要服从同一套事实边界
如果系统需要 AI 解释能力,可以通过:
- 模型 SDK;
- LangChain 等适配层;
接入模型服务。
但连接层和业务层应保持分离:
模型连接层负责
- 请求模型;
- 流式输出;
- 超时;
- 重试;
- SDK 兼容。
后端业务层负责
- 模型可以读取哪些资料;
- 哪些字段允许外发;
- 使用的是哪一版证据;
- 回答引用是否合法;
- 哪些行为允许执行。
二十四、模型回答也要绑定证据身份
AI 解释不应该只保存:
问题
回答
还可以保存:
问题
回答
模型版本
提示词版本
证据对象
证据版本
证据摘要
引用位置
生成时间
引用校验可以检查:
- 对象是否存在;
- 摘要是否一致;
- 引用位置是否存在;
- 当前回答是否允许使用该证据。
但也要明确:
引用合法,不代表推理一定正确。
因此仍然需要评估:
- 事实准确性;
- 缺少信息时是否正确拒绝;
- 是否虚构结论;
- 是否超出证据范围;
- 是否越过操作权限。
二十五、模型调用本身也是一种长任务
远程模型调用同样可能遇到:
- 超时;
- 中断;
- 客户端取消;
- 服务重试;
- 响应丢失;
- 重复请求。
因此,也可以使用类似原则:
- 有截止时间;
- 有取消记录;
- 有幂等请求;
- 有清楚的完成状态;
- 有结果身份。
需要注意:
本地停止等待,不等于远端计算或计费一定已经停止。
如果 AI 具备:
- 发邮件;
- 写数据库;
- 调外部 API;
- 修改资源;
等副作用能力,还需要单独设计这些操作的幂等和恢复语义。
二十六、最终要靠故障路径检验整套设计
依赖锁、构建产物和自动测试提供可重复的工程基础。
但真正检验这套长任务设计的,不只是正常路径。
还应该主动覆盖:
- 发布前中断;
- 发布后、数据库提交前中断;
- 数据库提交失败;
- 响应丢失;
- 旧会话消息迟到;
- 子进程未清理;
- 产物损坏;
- 重复提交;
- 输入变化;
- 通信队列满;
- Worker 无心跳;
- Worker 有心跳但无进展。
二十七、测试应该检查不变量,而不只是状态码
比“接口返回了 200”更重要的是检查系统的不变量。
例如:
不完整结果不能成功
暂存文件缺失
→ 任务不能标记为成功
资源未清理不能提前释放
旧 Worker 仍存活
→ 执行位不能直接交给新任务
旧执行不能覆盖新状态
attempt A1 迟到消息
→ 不得更新当前 attempt A2
幂等重放不能重复计算
同一 Idempotency-Key
+
同一请求内容
→ 返回已有任务
导出必须对应声明的结果
CSV / Parquet / JSON
→ 必须能追溯到同一个结果身份
二十八、不同证据不要相互扩大解释
工程系统中,经常会出现“证据范围扩大”的问题。
例如:
- 内容摘要一致,不代表来源一定可信;
- 依赖锁定,不代表所有环境因素都一致;
- 单元测试通过,不代表真实故障路径已经验证;
- 故障注入通过,不代表所有生产事故都已覆盖。
因此,可以分别记录:
- 单元测试证据;
- 集成测试证据;
- 故障注入证据;
- 真实运行观察;
- 手工验证;
- 环境身份。
一种证据只回答它真正能够回答的问题。
二十九、系统扩展以后,技术可以换,责任边界不要丢
随着业务增长,可以逐步替换:
- SQLite → PostgreSQL 等事务数据库;
- 单机 Worker → 多节点执行;
- 本地目录 → 对象存储;
- 本地队列 → 分布式消息系统。
但扩展到多节点以后,还需要重新设计:
- 租约;
- Worker 归属;
- 条件写入;
- 分布式执行身份;
- 执行隔离;
- 故障恢复;
- 节点失联;
- 结果发布竞争。
不能直接把单机假设搬过去。
真正值得长期保留的是每一层清楚的责任:
| 问题 | 必须有明确责任方 |
|---|---|
| 谁接纳任务? | 接口 / 控制层 |
| 谁拥有当前执行权? | 调度 / 控制层 |
| 谁执行计算? | Worker |
| 谁确认 Worker 已清理? | 监督器 |
| 谁决定结果可以发布? | 控制层 |
| 谁提交最终成功状态? | 事务层中的控制逻辑 |
| 谁确认结果仍然可读取? | 结果读取 / 完整性检查 |
| 用户凭什么相信任务完成? | 可检查的状态与结果证据 |
结语:可信的长任务,本质上是在管理事实边界
长任务系统真正困难的地方,不是把一个 Python 函数放到后台执行。
而是必须清楚地区分:
- 计算已经结束
- 结果已经写出
- 结果已经校验
- 结果已经发布
- 数据库已经提交引用
- 任务已经标记成功
- Worker 已经退出
- 资源已经释放
- 结果现在仍然可读取
这些事实经常不会在同一时刻成立。
因此,一个可信的长任务后端,不应该只维护一个简单的:
pending
running
success
failed
而应该围绕实际业务建立一套清楚的协议:
谁拥有执行权,谁确认清理,谁发布结果,谁提交状态,以及每一个“成功”究竟有怎样的证据。
Python、FastAPI、SQLite、Parquet、Decimal、SSE、子进程这些技术本身都很常见。
真正值得设计的,是它们如何组合成一种稳定的产品行为:
中断时不误报成功,重试时不重复执行,取消后能确认资源释放,结果可以核对,状态能够恢复,用户知道为什么这次工作可以被认为已经完成。