后端24 min read

常见技术栈如何构建可信的长任务后端

开始阅读

一项计算任务执行了二十分钟,进度已经显示 100%,随后服务突然重启。

任务究竟算不算完成?

答案取决于几件事:

  • 计算是否真的结束;
  • 结果是否完整保存;
  • 数据库是否已经记录结果引用;
  • 旧计算进程是否仍在运行。

进度条只能表达其中一部分事实。

这类问题广泛存在于:

  • 研究计算;
  • 数据处理;
  • 模型训练;
  • 批量导出;
  • 报告生成。

它们共同要求后端明确界定:

什么叫成功、什么叫失败、什么叫取消、什么叫恢复。

而且这些状态必须对应可以检查的事实。

本文介绍一种适合本地工具和中小规模单机服务的设计思路:

以 Python、关系数据库、文件产物和独立计算进程 为基础,通过分步发布、资源清理确认、多格式一致性校验和显式计算规则,构建可信的长任务流程。

这里讨论的创新价值,主要来自成熟技术的组合方式及其形成的产品行为,不以组件新颖程度衡量,也不宣称行业首创。


一、先给技术栈分配清楚的职责

一种可行的技术组合如下。

具体库可以替换,但职责边界应当保留。

层次 可采用的技术 主要责任
接口层 Python、FastAPI、Uvicorn 接收请求、提供查询、传递取消意图、输出状态事件
契约层 Pydantic、OpenAPI 验证输入与消息结构,描述接口,约束版本
控制层 Python 服务模块、状态机、监督线程 接纳任务、分配执行位、启动计算、裁定最终状态
计算层 独立 Python 子进程、业务计算库 读取冻结输入、执行计算、生成暂存产物
事务层 SQLite 与 APSW,或合适的数据库驱动 保存任务、幂等请求、执行归属、结果引用和业务记录
产物层 JSON、PyArrow、Parquet 保存完整结果及用于分析的结构化表
数值层 Decimal、整数或明确约定的浮点实现 执行与业务要求匹配的精度、舍入和比较规则
工程层 uv、构建工具、pytest 固定依赖、打包交付、验证正常与故障路径

浏览器负责观察,持久化状态负责确认

浏览器可以:

  • 通过 HTTP 查询任务;
  • 通过 SSE 接收状态更新。

SSE 的价值在于让页面及时刷新。

但需要明确:

状态事件是通知机制,持久化记录才是重新连接后确认任务状态的依据。

浏览器断线、刷新或服务重启后,不能只依赖之前收到的推送事件判断任务最终结果。

为什么计算适合使用独立进程

计算放在独立 Python 子进程中,可以带来几个好处:

  • CPU 工作与接口服务分离;
  • 能够独立观察计算进程退出;
  • 更容易实施超时;
  • 更容易执行终止;
  • 计算异常不必直接拖垮接口进程。

但需要注意:

普通子进程不等于安全沙箱。

如果系统需要执行不可信代码,还应额外设计:

  • 权限隔离;
  • 容器;
  • 用户隔离;
  • 文件系统限制;
  • 网络限制;
  • 或其他更强的执行环境。

SQLite 适合什么场景

SQLite 很适合这种单机起点。

一种常见方式是:

TEXT
写请求
  ↓
专用写线程
  ↓
SQLite / WAL

只读查询
  ↓
独立只读连接

专用写线程可以集中事务顺序,只读连接负责查询,WAL 有助于提高读写并行能力。

但 SQLite 仍然存在:

  • 写入吞吐限制;
  • 单机部署假设;
  • 跨节点协调能力有限。

因此:

适合作为单机长任务系统的起点,不应直接推广成多节点调度架构。


二、让“完成”对应一条结果发布协议

长任务通常会同时操作:

  1. 数据库;
  2. 文件系统。

数据库保存状态,文件系统保存:

  • 模型;
  • 报表;
  • 分析结果;
  • 导出内容。

问题在于:

数据库事务和文件系统操作之间,没有天然统一的事务。

因此,“计算完成”不能直接等价于“任务成功”。

一条更可靠的交付顺序

可以采用如下流程:

TEXT
接纳请求并持久化任务
        ↓
冻结输入
        ↓
启动 Worker
        ↓
写入暂存产物
        ↓
确认 Worker 清理
        ↓
校验并发布产物
        ↓
提交结果引用
        ↓
标记任务完成

也可以概括为:

接纳请求并持久化任务 → 冻结输入 → 启动 Worker → 写入暂存产物 → 确认 Worker 清理 → 校验并发布产物 → 提交结果引用 → 标记完成

关键原则是:

Worker 只负责报告“产物已准备”,控制层负责决定“是否可以发布为成功结果”。

数据库中的最终成功状态,也应由控制层提交。


三、暂存产物要能证明“它属于这次任务”

Worker 写出的暂存结果,不应该只是几个散落文件。

可以为每次任务生成一份产物清单,例如:

JSON
{
  "task_id": "...",
  "attempt_id": "...",
  "input_digest": "...",
  "format_version": 3,
  "files": [
    {
      "name": "result.json",
      "size": 123456,
      "digest": "..."
    },
    {
      "name": "metrics.parquet",
      "size": 456789,
      "digest": "..."
    }
  ]
}

清单至少可以描述:

  • 必要文件;
  • 文件大小;
  • 内容摘要;
  • 输入身份;
  • 任务身份;
  • 执行尝试身份;
  • 格式版本。

发布前,控制层不仅要检查:

文件有没有写出来?

还需要检查:

这些文件是不是本次任务、本次执行产生的结果?


四、不同失败窗口,要有不同处理

长任务最重要的不是“永远不失败”,而是:

失败发生在不同位置时,系统仍然知道现在有哪些事实成立。

失败位置 可以确认的事实 处理原则
计算过程中退出 任务没有交付完整结果 记录失败或中断,并检查执行资源是否释放
写暂存文件时退出 可能存在部分产物 不作为成功结果开放读取,保留诊断信息
文件已发布,数据库未提交 文件存在,但没有成功引用 识别为待核对产物,不凭文件存在自动补成成功
数据库提交后响应丢失 服务可能已经完成交付 通过幂等键或任务 ID 查询原结果
成功结果后来损坏 历史上完成,但当前无法读取 保留原完成事实,另报当前不可用及原因

这里有一个重要区别:

“历史上已经成功”与“现在结果仍然可读取”,是两个不同事实。

例如,一个任务昨天确实成功完成,但今天磁盘损坏了。

系统不应该把历史任务状态改写成“从未成功”,而应该表达:

TEXT
任务历史状态:完成
当前结果状态:不可读取
原因:产物完整性校验失败


五、文件发布也有自己的边界

文件系统操作看似简单,但其语义依赖:

  • 操作系统;
  • 文件系统;
  • 存储设备;
  • 是否跨设备;
  • 是否使用对象存储。

例如:

同一文件系统中的原子重命名,不能被理解成跨设备移动也拥有同样保证。

如果使用对象存储,也不能机械照搬本地文件系统的“rename 发布”模型,而应使用其支持的:

  • 条件写入;
  • 版本化;
  • 对象复制;
  • 清单提交;
  • 或其他发布协议。

因此,这套设计真正依赖的不是某一个具体 API,而是一个原则:

结果只有在完成校验并经过明确发布动作之后,才成为可引用结果。


六、把任务状态、执行身份和资源清理分开

“用户已经取消”与“计算进程已经停止”不是同一个事实。

例如:

TEXT
用户点击取消
    ↓
取消意图已记录
    ↓
Worker 收到终止通知
    ↓
Worker 可能还在清理
    ↓
进程真正退出
    ↓
执行资源确认释放

因此,接口收到取消请求后,可以先持久化:

取消意图

随后监督器:

  1. 通知 Worker;
  2. 必要时升级终止措施;
  3. 检查受管理进程是否退出;
  4. 确认相关资源是否释放。

只有满足清理条件,才应该释放对应执行资源。


七、业务状态与清理状态应该允许同时存在

任务模型可以分别保存:

  • 业务状态;
  • 运行阶段;
  • 清理确认;
  • 当前执行身份。

例如:

TEXT
业务状态:FAILED
运行阶段:CLEANING_UP
资源释放:false

一个任务已经失败,但仍在等待子进程退出,这完全是合法状态。

这种设计短期内可能降低可用执行位,但好处是:

不会为了快速恢复容量,让两个任务同时争用同一组受保护资源。


八、执行身份不要只依赖任务 ID

任务 ID 只表示:

这是哪一个业务任务。

但同一个任务可能经历多次执行尝试。

因此,一次实际运行可以同时拥有:

  • task_id
  • service_instance_id
  • session_id
  • attempt_id
  • 随机执行标识

例如:

TEXT
task_id = T1001
attempt_id = A3
session_id = S9f8...
service_instance_id = I2026...

控制层收到 Worker 消息后,应核对:

  • 任务身份;
  • 执行尝试;
  • 会话身份;
  • 消息顺序。

这样可以避免:

旧 Worker 的迟到消息覆盖新一次执行的状态。


九、服务重启后,不要只相信历史 PID

服务重启后,需要把:

  • 数据库里的任务状态;
  • 当前系统中真实存在的执行事实;

重新对照。

一个常见危险做法是:

读取数据库中的旧 PID,然后直接发送终止信号。

问题在于:

操作系统可能已经把这个 PID 分配给其他进程。

因此,进程生命周期管理应结合更多执行身份和启动上下文,而不是只依赖 PID。

进程组可以帮助管理子进程树,但也需要明确:

进程组是一种生命周期管理手段,不是对恶意子进程的完整安全约束。


十、幂等接纳要发生在再次启动计算之前

长任务接口很容易遇到重复提交:

  • 浏览器超时后重试;
  • 用户连续点击;
  • 网络代理重放;
  • 客户端没有收到响应。

因此,提交请求需要自己的身份。

一种常见做法是:

TEXT
Idempotency-Key
        +
规范化请求内容

系统应保证:

相同键 + 相同请求

返回原任务记录。

相同键 + 不同请求

返回冲突。

例如:

TEXT
key = abc123

request 1:
{"symbol":"AAPL","days":30}

request 2:
{"symbol":"AAPL","days":60}

如果两次使用相同幂等键,第二次应报告:

同一请求身份对应了不同内容。

而不是重新创建任务。

数据库约束是最后一道防线

重放查询应发生在再次启动计算之前,并通过数据库唯一约束,防止并发请求同时接纳同一个幂等请求。

需要注意:

幂等接纳只减少重复调度,并不能自动保证所有外部副作用恰好发生一次。

如果任务还会调用:

  • 支付接口;
  • 邮件发送;
  • 外部写 API;
  • 云存储写操作;

这些副作用仍然需要自己的:

  • 幂等键;
  • 去重机制;
  • 恢复规则。

十一、根据消息用途设计有界通信

长任务通常会产生多种消息:

  • 进度;
  • 心跳;
  • 诊断;
  • 日志;
  • 终态。

这些消息的价值并不相同。

进度:通常只需要最新值

例如:

TEXT
10%
11%
12%
...
73%
74%

页面一般只需要:

TEXT
当前进度:74%

因此,进度适合采用:

最新值覆盖旧值

不一定要排队保留每一次变化。

心跳:表达“仍然活着”

心跳可以合并成一个待发送信号,例如:

TEXT
last_heartbeat_at = ...

不必无限积压历史心跳。

终态:必须优先

一旦任务进入:

  • 成功;
  • 失败;
  • 取消;

这样的最终状态,就应该:

  • 优先传递;
  • 停止继续产生普通进度;
  • 尽快持久化。

十二、有界队列比无限队列更可信

消息通道如果无限增长,很容易出现:

TEXT
生产速度 > 消费速度
        ↓
消息持续积压
        ↓
内存持续上涨
        ↓
服务最终异常

因此,无论使用:

  • 受锁保护的槽位;
  • 有界队列;
  • socket;
  • pipe;

都应该明确:

  • 队列满怎么办;
  • 发送超时怎么办;
  • 通道关闭怎么办;
  • 终态能否优先进入;
  • 哪些消息可以覆盖。

这不是单纯的性能优化,而是任务语义的一部分。


十三、通信协议本身也要有边界

Worker 与控制层之间的消息不应该被当成“内部就一定可信”。

通信协议至少可以校验:

  • 帧长度;
  • 消息类型;
  • 必需字段;
  • 会话身份;
  • 执行身份;
  • 消息顺序;
  • 格式版本。

例如:

JSON
{
  "type": "progress",
  "task_id": "T1001",
  "attempt_id": "A3",
  "seq": 42,
  "progress": 0.74
}

控制层收到消息后,应确认:

这条消息是不是来自当前合法执行?

而不是仅仅看到 task_id 相同就接受。


十四、心跳不等于有效进展

一个 Worker 可以持续发送心跳,却卡在同一个地方几个小时。

因此:

心跳只能证明通信一侧仍有活动,不能证明计算取得有效进展。

长任务通常还需要:

  • 整体截止时间;
  • 阶段截止时间;
  • 必要的进度观察;
  • 卡死检测;
  • 用户取消机制。

例如:

TEXT
最后心跳:5 秒前
进度:27%
进度 40 分钟未变化

这说明:

进程可能仍然活着,但任务并没有取得可接受的进展。


十五、可以覆盖进度,不代表可以丢弃业务事件

进度和业务事实不能使用同一种保留策略。

可以覆盖:

  • 进度 63%;
  • 上一次心跳;
  • 某些临时诊断信息。

但不能轻易覆盖:

  • 成交记录;
  • 账本变动;
  • 状态转换;
  • 关键决定;
  • 用户操作;
  • 结果发布记录。

这些需要审计的事件,应单独持久化。

根据消息语义决定保留方式,是长任务通信设计的核心。


十六、让多格式存储与计算规则共同保证可核对性

同一份结果往往存在多种表示:

  • 完整 JSON:用于恢复和追溯;
  • Parquet:用于列式分析;
  • 页面摘要:用于展示;
  • CSV:用于用户下载。

格式越多,转换路径越多。

转换路径越多,出现不一致的机会也越多。

因此,应指定:

一个权威结果表示。

其他格式从权威表示按照确定规则生成。


十七、文件摘要与内容校验解决不同问题

例如,系统可以保存:

TEXT
result.json
metrics.parquet

可以对文件计算摘要,用来回答:

文件字节有没有变化?

但如果要回答:

Parquet 中的业务内容是否正确表达了 JSON 中的结果?

仅靠字节摘要是不够的。

一种更可靠的方式是:

TEXT
权威业务结果
      ↓
重新生成预期 Arrow 表
      ↓
读取实际 Parquet
      ↓
比较字段、类型、行数、元数据和内容

因此:

  • 文件摘要用于检测字节变化;
  • 内容对照用于检查业务表达。

两者解决的问题不同。


十八、大结果不一定适合一次性全量校验

全量对照会带来:

  • CPU 成本;
  • 内存成本;
  • I/O 成本。

结果很大时,可以使用:

  • 分区校验;
  • 分批校验;
  • 带身份的分块清单;
  • 增量校验。

如果采用抽样,也需要明确:

抽样只能提供局部证据,不能等同于全量一致性证明。

另外,Parquet 等列式文件的:

  • 压缩方式;
  • 编码策略;
  • writer 版本;

都可能变化。

因此:

业务语义相同,不一定意味着文件字节完全相同。


十九、数值规则也要成为正式契约

不同业务数值适合不同表示。

例如:

数值类型 常见选择
金额 整数最小货币单位,或 Decimal
数量 整数
比率 Decimal 或明确约定浮点
科学计算 浮点库 + 明确误差容限
百分比展示 基于权威数值派生

关键不是“全部用某一种类型”,而是:

规则需要明确、稳定、可复查。

使用 Decimal 时要固定上下文

可以对关键运算建立局部上下文:

PYTHON
with localcontext() as ctx:
    ctx.prec = ...
    ctx.rounding = ...

这样可以避免调用方的全局 Decimal 设置改变业务结果。

同时,可以区分:

  • 应当完全精确的运算;
  • 允许按照业务规则舍入的运算。

对于允许舍入的计算,可以保存:

TEXT
原始值
舍入后值
小数位
舍入规则
是否发生舍入
规则版本

需要强调:

Decimal 也是有限精度系统,不能描述成所有数学运算都天然精确。


二十、缺失值必须带业务语义

一个指标没有值,可能有很多原因:

  • 样本不足;
  • 输入缺失;
  • 计算失败;
  • 该指标不适用;
  • 尚未计算。

因此,一个指标可以不只保存:

JSON
{
  "value": null
}

而是保存:

JSON
{
  "value": null,
  "status": "unavailable",
  "reason": "insufficient_observations",
  "observation_count": 3
}

这样可以保持:

“没有足够样本” ≠ “结果等于零”

后续:

  • 页面;
  • 导出;
  • API;
  • AI 解释;

都应继续保留这个语义。


二十一、这些机制提高可核对性,但不能证明公式正确

需要明确能力边界。

多格式校验、摘要和数值规则可以帮助发现:

  • 输入变化;
  • 转换错误;
  • 舍入差异;
  • 保存问题;
  • 文件损坏。

但它们不能自动证明:

业务公式本身就是正确的。

公式正确性仍然需要:

  • 单元测试;
  • 对照案例;
  • 数学验证;
  • 专家审查;
  • 真实数据测试。

二十二、让接口契约与前端类型来自同一个来源

接口层可以通过:

TEXT
Pydantic Model
      ↓
OpenAPI
      ↓
TypeScript 类型

来减少前后端重复手写契约带来的漂移。

这样可以统一:

  • 请求字段;
  • 响应字段;
  • 枚举;
  • 可空性;
  • 基础类型。

但仍然需要强调:

代码生成提升开发期一致性,不替代运行时校验和业务不变量。

例如:

TEXT
end_time > start_time

这样的业务约束,即使 TypeScript 类型完全正确,也仍然需要后端检查。


二十三、AI 接入也要服从同一套事实边界

如果系统需要 AI 解释能力,可以通过:

  • 模型 SDK;
  • LangChain 等适配层;

接入模型服务。

但连接层和业务层应保持分离:

模型连接层负责

  • 请求模型;
  • 流式输出;
  • 超时;
  • 重试;
  • SDK 兼容。

后端业务层负责

  • 模型可以读取哪些资料;
  • 哪些字段允许外发;
  • 使用的是哪一版证据;
  • 回答引用是否合法;
  • 哪些行为允许执行。

二十四、模型回答也要绑定证据身份

AI 解释不应该只保存:

TEXT
问题
回答

还可以保存:

TEXT
问题
回答
模型版本
提示词版本
证据对象
证据版本
证据摘要
引用位置
生成时间

引用校验可以检查:

  • 对象是否存在;
  • 摘要是否一致;
  • 引用位置是否存在;
  • 当前回答是否允许使用该证据。

但也要明确:

引用合法,不代表推理一定正确。

因此仍然需要评估:

  • 事实准确性;
  • 缺少信息时是否正确拒绝;
  • 是否虚构结论;
  • 是否超出证据范围;
  • 是否越过操作权限。

二十五、模型调用本身也是一种长任务

远程模型调用同样可能遇到:

  • 超时;
  • 中断;
  • 客户端取消;
  • 服务重试;
  • 响应丢失;
  • 重复请求。

因此,也可以使用类似原则:

  • 有截止时间;
  • 有取消记录;
  • 有幂等请求;
  • 有清楚的完成状态;
  • 有结果身份。

需要注意:

本地停止等待,不等于远端计算或计费一定已经停止。

如果 AI 具备:

  • 发邮件;
  • 写数据库;
  • 调外部 API;
  • 修改资源;

等副作用能力,还需要单独设计这些操作的幂等和恢复语义。


二十六、最终要靠故障路径检验整套设计

依赖锁、构建产物和自动测试提供可重复的工程基础。

但真正检验这套长任务设计的,不只是正常路径。

还应该主动覆盖:

  • 发布前中断;
  • 发布后、数据库提交前中断;
  • 数据库提交失败;
  • 响应丢失;
  • 旧会话消息迟到;
  • 子进程未清理;
  • 产物损坏;
  • 重复提交;
  • 输入变化;
  • 通信队列满;
  • Worker 无心跳;
  • Worker 有心跳但无进展。

二十七、测试应该检查不变量,而不只是状态码

比“接口返回了 200”更重要的是检查系统的不变量。

例如:

不完整结果不能成功

TEXT
暂存文件缺失
→ 任务不能标记为成功

资源未清理不能提前释放

TEXT
旧 Worker 仍存活
→ 执行位不能直接交给新任务

旧执行不能覆盖新状态

TEXT
attempt A1 迟到消息
→ 不得更新当前 attempt A2

幂等重放不能重复计算

TEXT
同一 Idempotency-Key
+
同一请求内容
→ 返回已有任务

导出必须对应声明的结果

TEXT
CSV / Parquet / JSON
→ 必须能追溯到同一个结果身份


二十八、不同证据不要相互扩大解释

工程系统中,经常会出现“证据范围扩大”的问题。

例如:

  • 内容摘要一致,不代表来源一定可信;
  • 依赖锁定,不代表所有环境因素都一致;
  • 单元测试通过,不代表真实故障路径已经验证;
  • 故障注入通过,不代表所有生产事故都已覆盖。

因此,可以分别记录:

  • 单元测试证据;
  • 集成测试证据;
  • 故障注入证据;
  • 真实运行观察;
  • 手工验证;
  • 环境身份。

一种证据只回答它真正能够回答的问题。


二十九、系统扩展以后,技术可以换,责任边界不要丢

随着业务增长,可以逐步替换:

  • SQLite → PostgreSQL 等事务数据库;
  • 单机 Worker → 多节点执行;
  • 本地目录 → 对象存储;
  • 本地队列 → 分布式消息系统。

但扩展到多节点以后,还需要重新设计:

  • 租约;
  • Worker 归属;
  • 条件写入;
  • 分布式执行身份;
  • 执行隔离;
  • 故障恢复;
  • 节点失联;
  • 结果发布竞争。

不能直接把单机假设搬过去。

真正值得长期保留的是每一层清楚的责任:

问题 必须有明确责任方
谁接纳任务? 接口 / 控制层
谁拥有当前执行权? 调度 / 控制层
谁执行计算? Worker
谁确认 Worker 已清理? 监督器
谁决定结果可以发布? 控制层
谁提交最终成功状态? 事务层中的控制逻辑
谁确认结果仍然可读取? 结果读取 / 完整性检查
用户凭什么相信任务完成? 可检查的状态与结果证据

结语:可信的长任务,本质上是在管理事实边界

长任务系统真正困难的地方,不是把一个 Python 函数放到后台执行。

而是必须清楚地区分:

  • 计算已经结束
  • 结果已经写出
  • 结果已经校验
  • 结果已经发布
  • 数据库已经提交引用
  • 任务已经标记成功
  • Worker 已经退出
  • 资源已经释放
  • 结果现在仍然可读取

这些事实经常不会在同一时刻成立。

因此,一个可信的长任务后端,不应该只维护一个简单的:

TEXT
pending
running
success
failed

而应该围绕实际业务建立一套清楚的协议:

谁拥有执行权,谁确认清理,谁发布结果,谁提交状态,以及每一个“成功”究竟有怎样的证据。

Python、FastAPI、SQLite、Parquet、Decimal、SSE、子进程这些技术本身都很常见。

真正值得设计的,是它们如何组合成一种稳定的产品行为:

中断时不误报成功,重试时不重复执行,取消后能确认资源释放,结果可以核对,状态能够恢复,用户知道为什么这次工作可以被认为已经完成。

相关文章

2026.09.14回测跑完以后,还要回答什么
返回文章列表