这次做批量图片生成时,我遇到一个很像下载失败的问题:一次要生成 8 张 2K 图片,结果统计里只有 5 张。看到这个数字,人的第一反应通常是去检查下载器,看看是不是网络抖了一下,或者把下载重试次数再加几轮。因为最后呈现出来的确实是“少了 3 张”,而不是一个看起来很复杂的系统错误。
但我继续查资产库时,发现平台最后能找回 8 张图。也就是说,生成并没有真的丢失,丢的是后续流程对结果的观察和处理机会。更具体地说,页面轮询在第 5 张时就提前报错了,后面的下载逻辑根本没有机会执行。当结果数量变少时,先不要把“没拿到”直接等同于“没生成”。
先把“漏图”拆成几个不同的问题
“漏图”不是一个足够精确的故障描述。至少要分成 3 层:平台有没有生成,页面有没有等到生成状态,外层程序有没有活到下载完成。如果只看最终目录里的文件数量,这 3 层会被压缩成一个现象,排查自然会朝下载方向走偏。
这次的场景很典型。批量任务是 8 张,第一次统计只得到 5 张;随后在平台资产库里找回了全部 8 张。这两个观察放在一起,就说明“生成失败”不是最符合证据的解释。任务使用的是 0 额度门禁,也没有发生扣费,所以也不能把问题理解成因为用量不足而被平台中途截断。
如果不把这些层次拆开,后面会导致一种很浪费的处理:不断重试下载,却一直重放一个已经提前退出的页面等待过程。重试次数再多,也只是在同一个失败点附近打转。真正需要问的不是“下载还能不能再试”,而是“下载之前的等待是否已经结束得太早”。
页面已经停止等待,外层却还在等
排查后我看到,外层子进程的超时已经放宽了。乍看之下,这似乎足够解决长任务问题:只要给整个进程更多时间,批量生成就应该能完成。但实际结果没有变化,因为内层页面脚本仍然使用固定的轮询上限,原来的上限是 3600 次。
这就形成了两个互相独立的期限。外层进程愿意继续活着,不代表页面脚本愿意继续等待;页面脚本先结束,外层只能接收一个已经失败的结果。外层给出的时间再长,也不能让一个已经退出的内层等待重新开始。
异步批量任务的期限不是一个计时器,而是一条必须对齐的时间链。
这里最容易误判的是把“外层超时变大”当成系统已经获得了更多等待时间。实际上,真正决定页面能否看到完整结果的,是内层轮询何时结束。外层只负责容纳内层的执行和收尾;如果内层的期限更短,外层增加出来的时间就只是空白,不会自动转化成可用等待。
这件事让我重新意识到,超时不只是防止程序卡死的保险丝,也是流程状态的一部分。它决定系统愿意把一个结果视为失败,决定后续下载是否有机会发生,也决定错误信息最终会不会误导人。
错误捷径为什么看起来合理
最容易想到的错误捷径,是只增加下载重试次数。因为表面现象是少了 3 张图,增加重试似乎可以覆盖网络抖动、暂时找不到文件、接口返回不完整等情况。这个办法在真实的传输失败中有价值,但这次并不是传输阶段先失败,而是页面轮询提前结束。
另一个错误捷径,是只把外层子进程超时调大。它比增加下载重试更接近根因,却仍然不完整。只改外层,内层页面脚本依然会在固定上限到达时退出。最后得到的仍然是一个“外面还没到时间,里面已经不等了”的结构。
我没有直接选择这两种办法,是因为资产库已经提供了一个反证:8 张图最终都存在。这个证据把问题从“平台是否生成成功”推回到“本地流程是否等待并取回成功”。当一个现象同时有结果证据和过程证据时,我更愿意先修过程边界,而不是在末端反复补救。
期限应该随着批量规模变化
修复的核心不是把 3600 这个数字换成另一个更大的固定数字,而是让页面轮询期限根据批量规模计算。1 张图和 8 张图需要等待的墙钟时间不同,低分辨率和 2K 图片也可能处于不同的生成节奏。固定上限把这些差异全部抹掉,批量一变,原本勉强够用的等待就可能不够。
因此,我选择为每种批量规模计算墙钟预算。这里的预算不是精确预测每一张图需要多少秒,而是给流程一个与任务规模相关的合理上限,并保留收尾空间。页面轮询期限还必须严格短于外层进程期限,让外层有时间接收页面结果、整理已发现资产、输出错误状态,而不是两个进程同时撞上终止边界。
内层应该在外层之前结束,但不能早到让正常结果失去被观察的机会。
这个顺序很重要。若页面轮询期限等于外层期限,页面即使刚好发现全部结果,也可能没有时间把结果交给外层;若页面期限晚于外层,外层先被杀掉,内层再合理也无济于事。只有把两者当作一条链来设计,才能让“发现结果”和“保存结果”之间有可用的缓冲。
失败时也要留下可继续的状态
还有一个容易被忽略的部分:即使达到期限,错误也不能只写成“超时”。这次修复要求终止错误里报告已经发现了多少张、哪些任务仍处于进行中,以及是否可以继续生成。这样做不是为了让日志更长,而是为了让下一步判断有依据。
比如已经发现 5 张,剩下的任务仍在进行中,那么它和“平台只生成了 5 张”是两件不同的事。前者可能只需要继续等待或从资产库补取,后者才需要重新生成。如果错误信息把它们都压成同一个“超时”,人就只能重新猜测,重试也更容易变成重复提交。
如果不报告这些状态,会导致两个后果:一是把已经生成的结果误判为丢失,二是在不清楚状态时再次启动任务,造成重复生成或重复整理。一个可恢复的失败,应该告诉我已经完成了什么、还在发生什么,以及下一步是否有安全的继续路径。
这也是为什么我把“可继续生成提示”作为方法的一部分,而不是把它当成错误文案的润色。错误信息如果能帮助人区分继续等待、补取结果和重新生成,系统就拥有了恢复能力;如果只有一个模糊的失败词,系统实际上把判断成本全部交给了人。
8/8 说明修复覆盖了真正的链路
修复后,真实端到端结果是 8/8。这个结果的意义不只是数字从 5 变成了 8,而是证明页面等待、外层进程和后续下载之间已经重新接上。单独测页面轮询,不能说明下载会完成;单独测下载重试,也不能说明页面会等到完整结果。必须从批量提交开始,一直走到结果落盘,才能确认期限关系真的生效。
我会把这次验证分成两种判断。第一种是数量判断:请求 8 张,最终得到 8 张。第二种是过程判断:页面没有在第 5 张提前终止,外层仍有余量完成收尾,错误状态也没有把正常生成误报成丢失。前一种回答“有没有拿全”,后一种回答“为什么这次能拿全”。
一个流程成熟的标志,不是它能跑通一次,而是它能解释一次失败,并在下一次给出更稳的边界。8/8 是结果证据,期限按批量规模计算、内外层留余量,是可以迁移到其他批量任务里的方法。
下次我会怎么做
下次遇到批量结果变少,我会先按顺序确认 3 件事:平台是否完成生成,页面轮询在哪个阶段结束,外层进程是否有机会执行下载和收尾。只有把这 3 个问题分开,才不会因为最终文件少了就直接修改下载器。
然后我会检查所有相互嵌套的期限,而不是只看最外层的一个数字。批量规模、图片规格和任务阶段都可能影响墙钟预算;页面轮询必须严格短于外层进程期限,外层还要留下处理已发现结果和输出状态的余量。
最后,我会把失败信息设计成下一步行动的入口:已发现数量是多少,哪些仍在进行,能否继续生成或补取。这样即使任务没有在第一次运行里完整结束,也不会把人推回“再试一次”的盲目循环。
表面上我是在处理漏图,其实我是在处理一个异步系统对时间的理解。真正难的不是把计时器调大,而是让每一层都知道自己应该等到什么时候、结束后还要给谁留下什么。只要这条判断链被保存下来,下一次面对类似的批量任务,就不必从“是不是下载坏了”重新猜起。