夜雨聆风学习资料网

ARTICLE · 1343899

上传一个 Excel,拆成 N 张表:怎么让进度条不骗人

上传一个 Excel,拆成 N 张表:怎么让进度条不骗人

这篇只回答一个问题:一个要跑几分钟的后台任务,进度到底该由谁定义。


背景

「上传一张 Excel,按某一列拆成 N 张表,打包下载」—— 这类需求在企业内部工具里太常见了。业务代码其实不难:读表、分组、每组装一个文件、打个压缩包,半天能写完。

Excel 在这里只是载体。换成批量出图、报表生成、文件转码,只要形态是「一个输入 → N 个产物 → 打包下载」,下面这套做法都成立;产物是打包后落盘的,等状态进入终态才给出下载地址,不会跟着上传响应一起返回。

真正让人反复返工的是另一半:用户在这几分钟里看到什么。

我在一个最小实现里复现过三个几乎必然出现的症状:

1. 文件还在后台处理,上传区仍然可以点,于是又传了一次;

2. 全程只有一句「处理中,请稍候」,不知道在干嘛、还要多久;

3. 切到别的标签页再切回来,或者直接刷新,页面回到初始状态,进度和结果都不见了。

这三条看起来是三个 UI bug。但如果一个个去打补丁,通常会越改越乱。本文想说清楚的是:它们其实是同一件事 —— 系统里没有一份关于「这个任务现在怎么样了」的唯一真相源。 进度不该由前端画出来,应该由服务端定义、前端照着渲染。


1. 三个症状,其实是同一件事

症状一:处理中还能再传。 表面看是「按钮没置灰」,实际后果比 UI 严重。如果两个请求共用同一套输出路径(固定文件名、固定目录),第二次上传会和第一次并发写同一批文件,结果互相覆盖;即便前端置了灰,多开一个标签页、按一次刷新,锁就没了。更麻烦的是,很多人把「后端阻塞」理解成线程池不够,其实在单进程事件循环里,同步读表、同步写文件会让轮询请求连排队都排不上,用户看到的是「页面卡死」,不是「后端忙」。

症状二:没有过程文字。 前端往往写了提示,但把它绑在了 done/total 上:只有 total > 0 才显示「第几步」。而最耗时的恰恰是前置阶段 —— 读表、解析、确定要拆成几份。在这个阶段 total 还是 0,判定分支不成立,屏幕上只剩一句静态的「处理中」。等 total 终于有值,任务可能已经快跑完了。

症状三:切回页面被初始化。 状态、进度、结果卡、下载按钮全在 JS 内存里;下载链接甚至硬编码成一个固定地址。刷新一次,内存清空,页面回到初始态;更糟的是那个固定链接可能指向上一次、甚至别人的结果。

把三者并排看,缺口是同一个:任务的生命周期没有独立于「一次请求」和「一个页面」而存在。补 UI 是补不上的,得先定义一份状态。


2. 反模式一:状态住在请求里

最常见的写法是:上传接口里直接把活干完,返回处理结果。这在文件小、耗时短的时候没问题;一旦处理要几十秒到几分钟,就等于把任务的生命周期绑在了一条连接上。连接超时、用户刷新、开发时改代码触发热重载,任务都会被打断,而页面上还留着「处理中」三个字。

于是有人加一个全局状态文件:done=5、status=running。这比没有强,但有三个致命点,我在最小实现里逐一复现过:

• 只写不清:任务开始前不把上一次的 done 清零,页面一打开就显示上次的「已完成」;

• 错误分支不复位:处理函数里先置 running,中途抛异常直接返回,状态永远卡在 running;

• 不按会话隔离:所有用户共读同一个文件,A 的进度会显示到 B 的页面上。

这三条叠加的结果很荒诞:服务明明空闲,页面却报「全部完成」;出过一次错之后,页面永远卡在处理中。

正解是把「接单」和「干活」拆开,三步:

1. POST 只做三件事:存文件、生成 task_id、启动独立进程,立即返回 202;

2. 业务在独立进程里跑,把进度写进「每个任务一份」的状态文件;

3. 前端拿 task_id 轮询这份状态。

这里有个容易选错的分叉:不要只把同步代码丢进线程池。 线程池里的线程仍属于接单进程,进程一重启就全没了;要真正独立,得是独立进程,并在启动时脱离接单层的进程组。

状态层其实只有四个动作:创建、更新、判死、启动。四个核心源码文件(含这一层)与一份自检脚本都放在本篇第二条《可引入代码手册》里,正文只讲设计取舍,不铺代码。四个动作正好把前面三条坑和一处并发隐患一起堵上:

• 原子写用「同目录临时文件 + 替换」,保证轮询不会读到写了一半的 JSON;读取侧再兜一层,解析失败按「不存在」处理。

• 建任务时一次性把全部字段写成零值,这是「只写不清」的根治办法。不要等用到某个字段时再补写 —— 那正是残留值混进来的入口。

• 死进程判定是「错误分支不复位」的兜底:状态写着在跑、进程却没了,就改判「已中断」。注意要留几秒宽限期,否则刚建任务、进程还没起来时会被误判。

• 启动独立进程时让它脱离接单层的进程组,同时把会话标识一起写进状态对象,后面查询、下载、恢复三处都按它过滤,实现会话隔离。

实测:这个最小实现的上传接口在 6.3 毫秒内返回 202 {"task_id":"…","status":"queued"},之后的解析、拆分、打包都在独立进程里跑。

图 1|四个角色与一次请求的边界:连接早就断了,任务还在写状态


3. 反模式二:用数值猜完成

另一个高频写法是:前端发现 done >= total,就认定「处理完成」,解锁上传区、显示下载按钮。看着合理,实际至少三处会崩:

场景
为什么会崩
上一次残留的 done=total
新任务刚创建,轮询先读到旧值,立刻判定完成
total 还是 0
0 >= 0 成立,前置阶段就被当成完成
打包阶段
done 已满,但结果文件还没写完,此时解锁会让人下到半个文件

完成是一个状态,不是一个数值。 契约里要有显式的终态:success / error / interrupted。只有状态进入终态,才允许解锁上传区、才允许出现下载按钮;done 和 total 只负责显示快慢,不参与「是否结束」的判断。

前端也要改两处,但顺序不能反:

• 上传区用真正的 disabled,而不是只把 pointer-events 设成 none —— 后者挡鼠标,不挡键盘和代码触发;

• 事件回调里再判断一次当前是否 running,因为标签页切换、异步返回时全局状态可能已经变了。

最关键的一条:服务端必须同样拒绝。 同一会话同类任务已有活跃实例时,直接返回 409 并带上当前进度,而不是等前端自觉。CSS 不是权限,前端的任何锁都只是体验优化。实测里,重复上传被 409 挡住,响应里带着「处理进程已启动…」这样的实时阶段文字。

接单层因此可以做得很薄:上传接口里两道闸,其余接口只负责「读状态并鉴权」。完整实现见手册 2.3 节。

这些接口共享一条纪律:只要读状态,就必须按会话过滤,一个都不能漏。 状态层里的「找活跃任务」与「按会话列最近任务」两个查询,前者只找「仍在跑」的(最近一个可能已经结束了,不能拿它判断),后者按任务类型回最近一个完整状态并过滤过期数据。


4. 一份可复用的进度契约

我把面向前端的契约收敛成 8 个字段。状态对象另有任务 ID、会话标识、进程号等服务端字段(见手册 new_task),前端不必关心;下面这 8 个与具体业务无关,换成视频转码、报表生成、批量导入都成立:

字段
含义
谁来写
status
queued/running/success/error/interrupted
服务端
phase
当前在做什么(一句人话)
服务端
total / done
总步数 / 已完成步数
服务端
download_url
终态才有的结果地址
服务端
message / details
结果说明与告警明细
服务端
updated_at
最后更新时间
服务端

图 2|状态怎么流转、什么时候才解锁;文字进度与数字进度是两条独立的轨

这份契约里最重要的一条设计,是把「现在在做什么」和「走到第几步」拆成两个独立字段。

phase 是一句人话,任何时候都必须有值;total/done 是可选的数字,只有能数清楚步数时才有意义。二者独立,才能覆盖下面这个几乎所有长任务都有的结构:

阶段
total
done
前端应该显示
接收文件、启动处理
0
0
只有 phase 文字
解析源文件
0
0
只有 phase 文字(往往最耗时)
确定要拆成几份
N
0
文字 + 0 / N
逐份拆分
N
i
文字 + i / N + 百分比
打包、写入终态
N
N
文字(仍未解锁)

注意第二行:在 total 还是 0 的时候,用户最需要文字反馈,因为那正是「不知道要等多久」的阶段。如果前端把提示绑在 total > 0 上,这一段就是黑屏。

怎么在自己的业务里找到这五个写入点? 不依赖 Excel,也不依赖拆分:几乎所有长任务都是同一副骨架 —— 前置(还不知道总量)→ 总量确定 → 主循环 → 结果收尾 → 终态。在你的代码里,「读文件 / 解析 / 查数据库」那一段就是 ①;能算出 N 的那一行就是 ②;主 for 循环就是 ③;写结果、打包、上传那一段就是 ④;函数最后一行就是 ⑤。找到之后,业务函数本身一行都不用改,只在缝里插一次 phase(...) 写入。完整实现见手册 2.2 节。

还有三条写法值得固定下来:所有状态变更只走一个出口;工作目录显式传参,别覆盖模块级全局变量;异常兜底放在最外层入口,任何异常都落 error 终态。逐条设计理由见手册第 3 节。

我在最小实现里用一张 20 行、3 列、4 个地区的示例表真跑了一遍,轮询抓到 9 帧状态;按前端的显示规则换算成文字,实际显示是这样的:

[running] 处理进程已启动…[running] 正在解析源文件…[running] 正在拆分为 4 个地区表…(0 / 4)[running] 正在拆分 1 / 4:华东(1 / 4)[running] 正在拆分 2 / 4:华北(2 / 4)[running] 正在拆分 3 / 4:华南(3 / 4)[running] 正在拆分 4 / 4:西南(4 / 4)[running] 正在打包结果…(4 / 4)[success] 处理完成(4 / 4)

前两帧没有数字、只有文字,第三帧开始才出现分母 —— 这就是「两字段分离」在真实序列里的样子。顺带说明:为了让轮询能观察到每个阶段,demo 里给解析阶段加了 0.35 秒、每步加了 0.12 秒等待,这只是演示用的可观察延时,不是性能数据,不要拿它推算处理耗时。


5. 前端只是渲染器

前端的全部职责,是把服务端给的状态对象翻译成一行文字和一张结果卡。拆开只有三件事:渲染、轮询、恢复。完整可单测的模块见手册 2.4 节。

没有数字时只显示文字,有数字时追加括号 —— 就这一条规则,前面「没有过程提示」的症状消失了。这份前端模块我用模拟请求驱动跑过 29 项断言全部通过,其中包含「done=total 但 running 时仍禁用」「409 不记住任务也不轮询」「两条恢复通道都能接回终态」等边界。

由此还能推出四条纪律,都不依赖框架:

1. 忙闲只认状态。 解锁只认三种终态,所以打包阶段数字已满仍不会放行;上传回调里再判一次忙闲,挡住标签页切换、异步返回后状态已变的情形。

2. 前端不猜进度。 不要写「模拟动画」按时间匀速推进进度条 —— 它会和真实进度抢同一个宽度属性,最后出现「进度条到 100% 但任务还在跑」。真实进度来自服务端,前端只做赋值。

3. 结果不硬编码。 下载链接、结果说明、告警明细全部从状态对象里读;有多个任务类型时按各自的状态分别重绘,不复用上一个任务的 DOM。

4. 状态恢复走两条通道。 本地存 task_id(同一浏览器刷新立刻接上),服务端再提供 recover 按会话返回最近任务的完整状态(换浏览器、清了本地存储也能接回)。页面加载时先本地、后远端,然后统一轮询。

实测:同一会话的 recover 返回了含下载链接的完整状态对象;换成另一个会话标识,返回空对象 —— 恢复不会串到人。切标签页、刷新页面之后,进度和结果都还在,因为状态根本不在页面内存里。


6. 两个容易被忽略的坑

坑一:前后端版本不一致。 只要后端把「同步返回结果」改成「202 + task_id」,任何还开着的旧页面都会失配:旧 JS 解析不出结果字段,走到兜底分支显示「处理失败 / 未知错误」,而后端其实已经成功建了任务。用户以为失败,再传一次,后台就多跑一个长任务。更隐蔽的是 no-store 也救不了已经常驻内存的旧页面 —— 浏览器往返缓存恢复的是整个 JS 上下文。

服务端要主动兜住:给上传请求加一个版本头,旧版本直接 409,响应里写明「页面版本过旧,请强制刷新;本次未创建任务,已有结果不会丢失」,并且真的不创建任务。实测旧客户端拿到 409,状态文件数 0 → 0,一个野任务都没产生。这条是纯服务端能力,不依赖用户配合。

坑二:改代码打断正在跑的任务。 开发模式的热重载会重启接单层。如果业务跑在请求里,改一行代码就等于把正在处理的长任务杀掉,状态停在半途 —— 于是出现「越改越乱」的错觉。把业务放进独立进程,并在启动时让它脱离接单层的进程组,热重载就只影响接单层。实测:任务运行中主动改了一次接单层代码,日志出现 1 次重载,任务仍一路跑到 success 4/4,独立进程存活判定为真。

顺带两条,都是「任务有了 ID」之后自然该做的事:

• 结果文件名带 task_id,下载时按任务归属校验会话。实测跨会话下载返回 404,不会拿到别人的结果。

• 每个任务一个工作目录,中间文件、输出文件都写在里面。实测两个不同会话并发上传,各自 202、目录不同、各生成 4 个文件、都正常完成 —— 不再有同名覆盖。


7. 怎么搬进自己的项目

顺序不能反:先定契约 → 把重活从请求里摘出来 → 业务改独立进程 → 五条缝写进度 → 三处读状态按会话过滤 → 接前端状态机。契约的字段和终态规则一个字都不用改,换的只是承载它的组件:状态文件换数据库或 Redis、独立进程换任务队列,前端一行代码都不用动。

想抄什么
手册哪一节
四个核心源码文件 + 自检脚本
第 2 节
从零跑通的命令链与排错
第 1 节
六步接入与逐步验收
第 4 节
替换点、前端改装表与七条自检
第 5、6 节

全部源码、逐段设计理由、依赖清单与接入步骤都在本篇第二条《可引入代码手册:后端任务化 + 前端进度提示》。正文讲为什么,手册讲怎么抄,是同一套契约与实现。


8. 可迁移清单与实测数据

把上面的结论收成三条,与语言、框架、业务都无关:

1. 状态有唯一真相源:按 task_id 一份、原子写;phase 与 done/total 分离,total 未知时也要有文字。

2. 完成只认显式终态、解锁只在终态;服务端同样挡重复,前端的锁只是体验。

3. 恢复走「本地 ID + 服务端 recover」双通道;结果带 task_id 做归属校验,中间文件按任务隔离。

这次最小实现的实测数据:

项目
结果
上传响应
202,6.3 毫秒
状态序列
9 帧,running 0/0 → success 4/4
结果文件
zip 18,275 字节(示例表含时间戳,重跑有几字节浮动)
旧客户端 / 重复上传
均 409,状态文件 0 → 0
跨会话
下载 404、recover 返回空
并发两任务
各 4 个输出文件,目录隔离,各自成功
热重载
触发 1 次,任务仍完成
前端状态机
29 项断言全部通过(终态解锁、双通道恢复、409 不建任务)
配套手册源码
抽出后在独立环境真跑,13 项契约自检全部通过

边界说清楚:这套做法是单机轻量方案 —— 状态文件、独立进程、进程存活判定都假设在一台机器上。它没做任务队列、优先级、取消、结果过期清理,也没有多实例共享状态。并发量上来或者要跨机器,就把状态层换成数据库 / Redis 加任务队列,但上面那份契约的字段和规则不用改,换的只是存储。另外本文只做了接口级与逻辑级验证,没有做浏览器 UI 自动化实测。


小结

三个症状从来不是三个 UI bug:状态住在请求里、完成靠数值猜、结果只在页面内存,是同一处缺口的三种表现。把任务从请求里拆出来,给它一份明确的契约,前端就只需要老老实实渲染 —— 进度条不是画出来的,是约出来的。


踩坑速查表

现象
原因
修法
服务空闲却显示「已完成」
全局状态只写不清
每个任务独立状态,创建时清零
出错后永远「处理中」
running 未在错误分支复位
统一 try/finally + 死进程改判 interrupted
前置阶段没有任何提示
提示绑在 total > 0 上
phase 独立字段,无条件显示
进度到 100% 但结果没出
模拟动画与真实进度抢 DOM
删动画,宽度只由服务端赋值
刷新后结果丢失
状态只在 JS 内存
本地存 task_id + 服务端 recover
改代码后任务中断
业务跑在请求里
独立进程 + 脱离接单层进程组

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料