Appearance
什么时候该把文件处理接口改成异步任务
很多文件处理功能最开始都会写成同步接口。
例如:
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
FailedNot 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 更容易控制。