那次我在做一组批量提交。输入是 3 条任务,目标也很简单:提交完成后,逐条找到对应的结果记录,再把结果取回来。按直觉,这应该是一个“提交多少,取回多少”的流程。可实际结果很稳定:每次都只能取回 2 条,少的还是最后提交的那一条。
最开始看到“没有记录卡”时,我也差点把它归到网络波动里。异步页面有延迟,渲染有先后,最后一条刚好没准备好,听起来很合理。但重复结果很快把这个解释推翻了:串行提交会少,并行提交也会少;提交本身显示 3/3 成功,真正缺的是后面的结果捕获。当一个异步批处理稳定地少最后一条时,第一反应不该是重试,而应该先问结果身份是否被认错。
表面上是少结果,实际上是取错地址
这次现象有一个容易迷惑人的地方:生成并没有明确报错。提交阶段能看到 3 个任务都完成了提交,也能拿到 3 个工作区地址,可在后续下载时,最后一条地址对应的是一段空的旧会话。打开它以后,没有记录卡,没有目标图片,也没有匹配的提示词。看上去像是最后一条任务没有产出,实际上是程序拿着错误的地址去找产出。
如果不处理,后果不只是少一张图。批处理程序会把“找不到记录”解释成任务失败,接着可能重复提交整批任务,或者让人手工补最后一条。前者会增加无谓的用量,后者会破坏批处理的可追踪性:到底是生成失败,还是定位失败,最后很难再分清。
我于是把问题拆成两个阶段:第一阶段是任务是否真的提交并生成;第二阶段是程序是否准确捕获了本次提交产生的工作区。第一个阶段已有证据支持,第二个阶段才需要继续追。
为什么偏偏总是最后一条
提交页面并不是一个每次都从空白开始的容器。新页面跳转的初期,地址栏会短暂显示账号最近已有会话的旧工作区。这个旧地址出现得很快,而真正属于新提交的工作区地址要稍后才稳定下来。
原来的轮询逻辑只做了一件事:只要看到地址里出现任意工作区参数,就认为页面已经跳转完成。于是,前面的任务可能碰巧等到了新地址,最后一条却在跳转尚未稳定时,先读到了那个旧地址。旧轮询没有能力分辨“页面已经有一个地址”和“页面已经到了本次任务的地址”之间的差别。
这也解释了为什么问题会集中在最后一条。前面任务的页面状态已经被一批操作推动过,最后一次打开新页面时,最近会话更容易被短暂恢复;而最后一项又通常是批处理收尾时才开始下载,错误地址因此直接进入了结果捕获环节。工作区编号出现倒挂,也给了一个旁证:最后捕获到的编号比前面任务的编号更旧,不符合本批提交的顺序。
异步页面里“出现了一个合法参数”,不等于“出现了属于本次操作的合法状态”。
固定等待为什么不是根治
最容易误判的做法,是把最后一条当成偶发网络丢包,然后增加几秒固定等待。这个办法有时会让现象暂时消失,因为页面确实可能在等待期间完成跳转。但它没有回答关键问题:等待结束时,程序看到的地址究竟是谁的?
固定等待有两个相反的风险。等得短,旧地址还在,竞态仍然存在;等得长,流程会变慢,但并不能保证网络抖动、页面恢复或渲染顺序改变时仍然可靠。直接重试整批任务则更危险,因为原批可能已经成功,只是捕获阶段拿错了身份,重试会让重复提交看起来像修复。
我没有先加等待,是因为证据已经指向状态识别,而不是时间不足。页面不是“过了 5 秒就一定正确”,而是要满足一个可验证的条件:新地址必须和提交前看到的旧地址不同,并且还要属于当前这次提交。把时间当作状态的替代品,只会把一个可解释的问题变成一个概率问题。
从旧状态开始定义新结果
修复的起点,是在提交前记录快照。快照至少要留下当前页面已经存在的工作区标识,以及本次任务能够用来确认归属的必要信息。进入轮询后,先拒绝提交前已经看到的旧标识;即使页面已经显示了工作区参数,只要它没有发生变化,就不能收尾。
然后,轮询等待的是“新状态出现”,不是“某个字段出现”。新状态要同时满足几个条件:地址中的标识与旧快照不同,页面仍然处在当前任务的上下文里,结果区出现了后续下载所需的必要字段。只有这些条件共同成立,程序才把工作区交给下一步。
这里的关键不是把轮询次数从 30 次改成 60 次,也不是把休眠时间从 1 秒改成 3 秒,而是改变结束条件。可靠的异步等待,等待的是状态约束被满足,而不是时钟走到了某个刻度。
这个判断也让错误更容易定位。如果超时,说明“新状态没有出现”或“新状态缺少必要字段”,而不是模糊地说页面太慢。后续可以继续观察页面跳转、任务提交或结果渲染的具体阶段,不必把所有问题都塞进一个更长的等待时间。
先红后绿,再做真实验证
为了避免修复只是碰巧改变了时序,我先用一个最小测试模拟旧地址残留的场景:页面先返回提交前的工作区,再返回一个新工作区。旧逻辑会在第一步就结束,测试应当失败;加入“必须不同于旧状态”的条件后,测试才允许通过。这个测试的价值不在于复刻整个网站,而在于把竞态的必要条件固定下来。
接着做串行实测。3 个任务逐个提交,要求捕获 3/3,3 个工作区互不相同,并且后续结果都能取回。结果是 3/3 捕获成功,且没有产生额外用量。这个结果说明修复没有依赖并行时序,也没有通过重复提交掩盖问题。
并行验证仍然要独立收尾,因为串行通过不能证明并发状态隔离正确。并行时,每个任务都要有自己的提交前快照和自己的结果归属判断;共享一个“最近工作区”变量,会把原来的问题换一种方式带回来。这里我把串行和并行分开看,是因为两者都提交成功,并不代表它们在捕获阶段面临同一种状态竞争。
这件事还暴露出一个常见误区:把成功分成“页面点到了提交按钮”和“结果可以被准确取回”两个层次。前者只能证明动作发出去了,后者才证明系统知道自己取回的是什么。对异步流程来说,身份验证是结果的一部分,不是附加日志。
一套可以复用的状态校验流程
以后再遇到异步批处理少结果,我会按下面的思路检查,但不会把它写成一串只适用于某个页面的操作口诀。
先问结果的身份是什么。它可能是工作区编号、任务编号、文件名组合,或者一段由提交动作产生的唯一状态。没有身份,就无法判断一个看似合法的结果是否属于当前任务。
再记录提交前快照。快照不是为了保存更多日志,而是给“新结果”划一条边界。之后轮询时,明确拒绝快照中已有的状态,避免页面恢复旧内容时被提前收尾。
然后定义必要字段。只看到地址参数通常不够,还要确认页面或响应中已经出现后续动作真正依赖的字段。字段越接近业务结果,条件越有意义;只检查外壳上的一个参数,仍然可能捕获到空页面。
最后把验证分成最小测试、串行实测和并行实测。最小测试负责证明竞态被识别,串行实测负责证明正常流程不回退,并行实测负责证明状态不会互相串台。每一层都要记录捕获数量、结果数量和异常归属,不能只看最后有没有报错。
这套方法的核心,是把“我等了一会儿,页面应该好了”改成“我看到了一个与旧状态不同、且满足必要条件的新状态”。它适用于页面跳转、异步任务、队列消费、文件生成和接口轮询,只要系统存在旧状态残留,就值得先做身份约束。
下次我会怎么做
下次遇到批量结果少 1 条,我会先按提交、捕获、下载 3 个阶段拆开计数。提交数少,查提交;提交数齐而捕获数少,查状态归属;捕获数齐而下载数少,再查结果内容。这样做的好处是,不会把后一个阶段的定位问题错误地归咎于前一个阶段的生成失败。
我会在每次提交前保存旧状态,并把“新状态不同于旧状态”写成明确的结束条件。如果页面还会短暂显示旧内容,就继续增加属于当前任务的必要字段校验,而不是先加固定等待。确实需要等待时,等待也应服务于状态条件,而不是取代状态条件。
我还会坚持把串行和并行分开验收。一个流程在 3 条任务上跑通一次,不代表它能在下一批任务里稳定识别归属。真正成熟的流程,不是它能在顺利时快速结束,而是它在旧状态、慢跳转和并发交错出现时,仍然知道自己在等什么。
这件事让我重新意识到,异步系统最难的部分常常不是“让任务跑起来”,而是让系统有资格说“这个结果就是刚才那次操作的结果”。批处理可靠性的底线,不是最终拿到了多少条,而是每一条都能证明自己属于正确的提交。