Skip to content

什么时候该把文件处理接口改成异步任务

很多文件处理功能最开始都会写成同步接口。

例如:

text
上传文件

解析

校验

保存

返回结果

只要文件不大、处理逻辑不复杂,这种方式最直接,也最好维护。

但随着处理步骤变多,一次 HTTP 请求里可能逐渐塞进:

text
上传文件

解析

数据校验

调用第三方接口

数据库写入

生成结果文件

返回下载

这时真正的问题往往已经不是代码执行得够不够快。

而是一个可能持续几十秒甚至更久的任务,是否还应该绑定在一次 HTTP 请求的生命周期里。

同步接口的限制不只在上传

最容易想到的是大文件上传。

文件越大:

  • 上传时间越长;
  • 用户网络波动影响越明显;
  • 网关和反向代理更容易触发超时;
  • 请求失败以后,前面的等待时间全部浪费。

但文件处理的另一端也有类似问题。

例如接口需要:

text
查询数据

生成 Excel

压缩文件

返回下载

即使用户上传的内容很小,生成和下载过程本身也可能很慢。

因此问题不应该只理解成:大文件上传要异步。

更准确的是任何长时间占用一次 HTTP 请求的文件处理流程,都需要重新评估同步接口是否仍然合适。

一次 HTTP 请求承担了太多责任

同步方式下,请求和任务通常绑定在一起:

text
HTTP 请求开始

业务任务开始

一直处理

业务任务结束

HTTP 请求返回

这种结构隐含了一个前提:业务任务必须在 HTTP 连接可以接受的时间内完成。

但真实系统中,连接链路可能经过:

text
浏览器

CDN / 网关

Nginx

应用服务

第三方服务

每一层都可能有自己的超时时间。

即使应用最终能够在 90 秒后完成任务,如果前面的代理在 60 秒就断开连接,用户看到的仍然是失败。

这时继续单纯增加 HTTP timeout,通常只能把问题往后推。

异步化真正拆开的是什么

异步任务的核心不是“开一个线程”。

真正需要拆开的是:

text
提交任务

和:

text
等待任务完成

同步方式:

text
提交

执行

等待

完成

返回

异步方式:

text
提交

立即返回 taskId

后台

执行任务

用户拿到 taskId 后,再查询任务状态。

于是 HTTP 请求的职责从等到整个业务处理完成变成成功接收这个任务,并告诉客户端以后怎么找到它

为什么最后还是 taskId + polling

这里其实有很多选择:

  • WebSocket;
  • SSE;
  • MQ 回调;
  • Webhook;
  • taskId + polling。

对于普通后台管理系统或低频文件处理功能,我最后更倾向于:

text
taskId + polling

原因不是它最先进,而是它足够简单。

客户端提交任务:

text
POST /import

返回:

json
{
  "taskId": "550e8400-e29b-41d4-a716-446655440000"
}

这里的 taskId 使用 UUID。

后台开始执行。

前端每隔一段时间查询:

text
GET /tasks/{taskId}

直到任务进入终态。

这个方案没有额外的长连接,也不要求浏览器一直保持某个会话通道。

即使页面刷新,只要 taskId 还在,也可以继续查询。

状态不能只有“处理中”和“完成”

最初设计任务状态时,很容易只想到:

text
Processing
Completed

但这少了一个关键终态:

text
Failed

更完整的最小状态模型是:

text
Processing
Completed
Failed

其中:

text
Completed
Failed

都属于终结态。

任务执行失败以后,如果状态仍然保持 Processing,前端会一直轮询:

text
Processing
Processing
Processing
...

用户无法判断任务是:

  • 还没完成;
  • 已经卡住;
  • 还是早就失败了。

因此异常处理不能只是记录日志。

它还必须改变任务状态。

Failed 和 Completed 都意味着“不要再等了”

从业务结果看 Completed 表示 任务已经结束,并且成功

Failed 表示任务已经结束,但失败

两者业务含义完全不同,但在状态机里有一个共同点都不应该继续轮询等待。

所以前端逻辑可以很简单:

text
Processing

继续查询

Completed

停止轮询,展示结果

Failed

停止轮询,展示失败原因

这比单纯用:

text
done = true / false

更容易表达真实状态。

查询结果还需要处理“不存在”

任务内部状态仍然只有:

text
Processing
Completed
Failed

Not Found / Expired 不是任务状态机里的第四个状态,而是查询接口在找不到任务记录时需要明确返回的一种结果。

例如任务状态设置了 TTL,过期以后 Redis 中已经不存在这条记录。

客户端不能把这种情况继续按照 Processing 处理。

因此查询接口对外至少需要覆盖:

text
Processing
Completed
Failed
Not Found / Expired

其中后三种都意味着停止继续轮询。

为什么把任务状态放到 Redis

异步任务提交以后,后续查询请求不一定还会落到最初执行任务的那个应用实例。

如果状态只存在内存里:

text
实例 A

创建任务

保存状态

下一次轮询可能进入:

text
实例 B

它根本不知道这个 taskId

因此任务状态需要放到共享存储中。

对于这类短生命周期任务,Redis 很合适:

text
taskId

status

message

result

例如:

text
task:550e8400-e29b-41d4-a716-446655440000
  status = Processing

任务完成以后:

text
status = Completed
result = ...

失败时:

text
status = Failed
message = ...

再设置一个合理 TTL,让历史任务自动过期。

这里 Redis 保存的是任务运行状态,不是长期业务数据。

真正需要长期保留的业务结果仍然应该进入数据库或文件存储。

taskId 仍然不是权限凭证

taskId 使用 UUID,可以避免简单枚举带来的问题。

但 UUID 只是任务标识,不代表访问权限。

查询:

text
GET /tasks/{taskId}

时仍然需要结合当前登录身份校验任务归属。

例如:

text
当前用户
当前租户
taskId

三者需要能够对应起来。

也就是说:

text
知道 taskId

不等于:

text
有权查看 taskId 对应的任务

UUID 解决的是“难猜”,权限校验解决的是“能不能看”。

如果任务结果里包含文件地址或业务数据,这个边界尤其重要。

Processing 也不能无限持续

正常异常可以通过:

text
Processing

Failed

结束任务。

但还有一种情况无法依赖普通异常处理解决:执行任务的应用实例直接崩溃或重启。

如果任务只是运行在应用内线程池中,进程退出以后,Redis 中可能仍然保存:

text
status = Processing

但已经没有任何线程继续执行这个任务。

这时就会形成“僵尸 Processing”。

因此任务记录最好同时保存:

text
创建时间
开始时间
最后更新时间

必要时还可以增加:

text
最后心跳时间
预期最大执行时长

如果一个任务长时间停留在 Processing 且没有进展,就不能让前端无限等待。

简单系统可以先设置一个最大等待时间:

text
超过合理任务时长

停止当前轮询

提示任务处理时间异常

如果任务必须在应用重启以后继续执行,则需要进一步使用持久化任务队列或其他可靠任务机制,而不能只依赖应用内线程池。

轮询频率不需要太高,也不能无限轮询

文件处理任务通常不是毫秒级完成。

因此没有必要让前端每 100ms 查询一次。

例如:

text
1~3 秒

通常已经足够。

轮询频率应该和任务耗时量级匹配。

如果一个任务正常需要几十秒,2 秒查询一次,对用户体验几乎没有影响。

同时,前端还应该设置合理的停止条件。

例如:

text
任务进入终态
→ 停止轮询

或者:

text
超过最大等待时间
→ 停止轮询
→ 提示用户稍后重新查看

这样即使后台出现异常,也不会让浏览器一直无限查询。

上传成功,不代表任务成功

异步化以后,需要特别区分两个“成功”。

第一个成功是:文件已经上传,任务已经创建。

第二个成功是:文件已经正确处理完成。

例如:

text
上传成功

taskId 已生成

后台解析文件

发现格式错误

Failed

这时 HTTP 提交接口本身完全可以返回成功。

因为它已经成功接收任务。

真正的业务失败通过任务状态表达。

如果不区分这两层,很容易出现:HTTP 200 = 整个业务已经成功

这种错误理解。

下载也可以拆成两个阶段

对于需要生成结果文件的场景,也不一定要求一次请求里完成:

text
生成文件
+
下载文件

可以先:

text
提交生成任务

返回 taskId

后台生成文件

完成以后:

text
Completed

返回 fileId / downloadUrl

用户再发起下载

这样:

text
生成文件

和:

text
传输文件

也被拆成了两个独立阶段。

如果下载连接失败,文件本身仍然已经生成,不需要重新执行整个业务处理。

如果返回的是 downloadUrl,还需要考虑下载地址本身的权限和有效期。

对于私有文件,更适合:

text
受鉴权保护的下载接口

或者:

text
带有效期的签名 URL

而不是长期暴露一个任何人拿到都能访问的公开地址。

重复提交也需要明确规则

异步任务创建以后,还需要提前决定同一个业务任务是否允许重复提交

如果重复执行会产生副作用,可以增加业务幂等键。

例如客户端生成:

text
requestId

后端创建任务前先检查:

text
requestId

是否已经存在对应 taskId

如果已经存在,可以直接返回原来的 taskId,而不是重新创建一个任务。

是否需要这样做,取决于任务本身是否允许重复执行。

什么时候值得改成异步任务

不是所有文件接口都需要异步化。

如果一个接口:

  • 文件很小;
  • 处理时间稳定;
  • 没有第三方慢调用;
  • 通常几秒内完成;
  • 出错以后重试成本很低;

同步接口反而更简单。

我更关注这些信号:

  • 文件大小开始不可控;
  • 正常处理时间已经接近几十秒;
  • 中间包含第三方接口;
  • 包含大量数据库处理;
  • 需要生成较大的结果文件;
  • 网关或代理曾经出现超时;
  • 用户经常需要一直停留在页面等待;
  • 失败后重新执行的成本较高。

出现这些情况以后,再继续增加 timeout 往往不是最合适的方向。

异步化以后也会增加复杂度

异步任务不是免费的。

原来的同步接口只需要:

text
请求

处理

返回

异步以后至少需要:

text
taskId
状态存储
后台执行
状态查询
失败处理
TTL
前端轮询
结果获取

还需要考虑:

  • 任务是否允许重复提交;
  • 应用重启时任务怎么办;
  • 执行到一半失败是否可以重试;
  • 文件什么时候删除;
  • Completed 后结果保留多久;
  • 查询 taskId 时如何校验归属;
  • Processing 超过合理时间以后怎么处理。

所以不能因为“异步更高级”就默认使用异步。

它真正适合解决的是业务处理时间已经不适合继续绑定 HTTP 请求生命周期。

最后的结构

最后形成的模型很简单:

text
客户端

提交任务

返回 UUID taskId

后台:

text
taskId

Processing

执行文件处理

Completed / Failed

客户端:

text
taskId

每隔 1~3 秒查询状态

Processing          → 继续等待
Completed           → 获取结果
Failed              → 展示错误
Not Found / Expired → 停止查询

与此同时:

text
taskId

必须经过用户 / 租户归属校验

复杂任务的生命周期和一次 HTTP 请求,从这里被拆开了。

结论

异步任务真正解决的不是:文件处理代码运行得慢。

而是一个长时间任务不应该必须依赖一次 HTTP 连接从头保持到尾。

taskId + polling 看起来并不复杂,但它把几个重要边界明确分开:

text
提交任务
任务执行
状态查询
结果获取

对于上传、解析、第三方调用、文件生成和下载这类耗时可能逐渐增长的功能,这种拆分通常比不断增加 HTTP timeout 更容易控制。