任务明明完成了,PM2轮询为什么随机404? 那天我一次导入了 5 个文件3 个 PDF、1 个 DOC、1 个 Excel。DOC 和 Excel 正常其中一个图片型 PDF 也正常。另外两个 PDF 就很奇怪了点进详情以后上面一直转 loading下面写着“AI正在解析中”。我等了很久没变化。把应用杀掉重新打开还是那两个字解析中。最烦的不是它明确报错而是它一直给你一种“再等一会儿可能就好了”的错觉。结果你茶都喝完了它还在原地认真转圈。上一篇《AI 又把改好的功能改坏了我为什么开始研究 Harness》讲的是怎样用规则和证据约束 AI 交付。这次遇到的问题更直接后端任务其实已经跑完了客户端为什么就是拿不到结果我先怀疑 AI 通道和任务队列文本 PDF 可以直接抽取内容图片型 PDF 还要经过页面渲染和视觉识别复杂文档可能要调用 MinerU再把文字、图片和页面信息组装成阅读包。页面一直显示解析中我第一反应自然是 AI 通道出错了。接着我又怀疑任务队列是不是任务创建了但没人消费是不是某一步失败后没有把状态改成 failedMinerU 本身的处理速度和并发瓶颈我也考虑过。继续查以后情况反而更怪那两个 PDF 对应的任务在服务端已经到了done/view_ready解析结果也存在。客户端轮询状态时却经常拿到404 Job not found or expired任务明明完成了怎么还能“找不到或已过期”昨天 Dev 还正常怎么一到生产就坏了我当时在想昨天测试环境不是好好的吗同一套解析流程为什么到了生产就出问题顺着这个差异往回想我突然反应过来Dev 当时只有一个节点生产跑了 8 个 PM2 worker。这个坑我以前在别的地方遇到过。单节点时请求创建任务、更新任务、查询任务始终在同一个进程里转。到了多节点或多进程环境后一次请求不一定还会回到原来的进程。我再去看文件解析的任务状态问题就对上了。旧实现可以缩成下面这样constjobStorenewMapstring,ParseJob();// 创建任务jobStore.set(jobId,{status:processing,progress:0/10});// 查询任务constjobjobStore.get(jobId);if(!job){returnreply.status(404).send({error:Job not found or expired});}这段代码放在单进程里能跑部署到 8 个 worker 后就不一样了这个Map只属于当前 Node.js 进程。同一台服务器也可能有八份内存生产使用的是 PM2 cluster 模式module.exports{apps:[{name:server,script:dist/index.js,instances:max,exec_mode:cluster}]};PM2 会启动多个子进程它们可以共同监听服务端口但每个进程都有自己的 JavaScript 堆。Worker A 里的jobStore和 Worker B 里的jobStore名字一样实际是两份互不认识的数据。于是这次故障的完整过程变成了上传请求 - Worker A 创建任务 - Worker A 的 Map 保存 job_xxx - 后台解析完成Worker A 写入 done 客户端轮询 - 请求落到 Worker B - Worker B 查询自己的 Map - 找不到 job_xxx - 返回 404同一个任务落到创建它的 worker 能返回200 done/view_ready落到其他 worker就变成 404。这也解释了为什么应用杀掉重进没用。问题不在页面保存了一个旧 loading而在每次状态查询都可能被分到另一份内存。Java 开发者也别急着笑 Node.js。把异步任务放进 Spring Boot 单例 Bean 里的ConcurrentHashMap然后横向启动 8 个实例效果是一样的。static、单例、模块级变量只能保证一个进程内部能看见不能让多个实例自动共享记忆。PM2 官方文档在 cluster mode 后面专门提醒应用要尽量无状态进程之间需要共享的状态应该放进 Redis、MongoDB 等外部存储。PM2 Cluster Mode 官方文档为什么单节点测试一直测不出来Dev 只有一个节点时创建和查询都读取同一份Map。只要任务逻辑能跑完连续点多少次都正常。这类问题如果只做下面这种验证很容易误判启动一个服务 上传一个文件 拿返回的 jobId 轮询 看到 done 结束它只能证明单进程链路能工作证明不了多 worker 下的状态可见性。复现时可以关闭连接复用让请求更有机会落到不同 worker但这并不能保证换进程。最后还得回到日志用jobId pid确认请求到底去了哪里。单文件 job 要测批量任务的batchId也要测。一开始我关注的是“任务有没有跑完”后来真正帮我定位问题的是另一组信息同一个任务由谁创建又由谁查询。第一刀把任务状态从 Map 搬到 Redis临时把生产改成单 worker确实可能让 404 消失但多核利用、吞吐余量和故障隔离也会跟着打折。Sticky session 也不是这类任务状态的好归宿客户端重连、应用重启、扩缩容或 worker reload 后请求仍可能换地方。这次采用的是 Redis 共享短期状态。单文件 job、批量任务和批量中的每个文件分别存储file-parse:job:{jobId} file-parse:batch:{batchId}:meta file-parse:batch:{batchId}:file:{index}Worker A 创建任务以后先把 processing 写进 RedisWorker B、C、D 收到轮询请求时也从 Redis 读取同一份状态。批量任务的文件状态拆开存避免多个文件并发完成时都去覆盖同一个大数组。状态接口随后拆成了三种结果Redis 中确实没有任务或 TTL 已到期才返回 404Redis 连接失败或命令超时返回 503任务执行失败时状态仍然存在只是变成 failed 并带上原因。如果 Redis 挂了也返回 404客户端会以为任务不存在然后重新提交。原本一次基础设施故障很可能被放大成一批重复解析任务。Redis 里还得拦住迟到的进度状态共享只解决“大家能不能看见”并发更新又是另一个问题。假设两个 worker 同时读取同一任务一个准备把它改成 done另一个还在上报6/10。如果双方都执行“先 GET、合并对象、再 SET”迟到的进度更新可能把 done 覆盖回 processing。项目里把done和failed定义成 terminal 状态。更新时按WATCH - GET - MULTI - SET EX - EXEC执行从读取到提交之间只要 key 被其他请求改过EXEC就返回空本次更新重新读取最多尝试三次。核心逻辑可以缩成for(letattempt0;attempt3;attempt){awaitredis.watch(key);constcurrentawaitreadJob(key);if(current.statusdone||current.statusfailed){awaitredis.unwatch();returncurrent;}constnextmergeProgressWithoutGoingBack(current,patch);constresultawaitredis.multi().set(key,encode(next),EX,ttlSeconds).exec();if(result!null)returnnext;}thrownewError(state update conflict);真正写入时终态判断和SET EX要放在同一个事务重试边界里。WATCH是连接级状态这组命令还必须独占同一条连接如果客户端会并发复用连接就改用独占连接或 Lua 条件更新。Redis 官方把WATCH定义为一种 check-and-set被监视的 key 在EXEC前发生变化事务会中止由调用方重新读取和提交。Redis Transactions 官方文档TTL 也不能只设一个数字。processing 要覆盖正常解析时间done/failed 则从终态开始保留轮询窗口。TTL 只负责过期清理不等于任务存活检测长任务还需要 heartbeat 或 lease否则活任务可能过期死任务也可能一直等到 TTL。Redis EXPIRE 官方文档解析结果太大时也不能整包硬塞进 Redis。正文压缩后超过预算就把短期 payload 单独保存Redis 只保留 pointer、大小和校验值。这次是同一台机器上的 PM2 worker共享本机目录还能读取扩到多主机或隔离容器时pointer 必须指向共享卷或对象存储。随机 404 修好了详情还是不对我原本以为把状态放进 Redis这事就结束了。修好以后我第一时间拿原来的 5 个文件重新测试结果 loading 确实不转圈了但详情里的资源还是不对。继续排查发现之前为了兼容多环境资产端侧会改写文件资源地址。上传和解析发生在当前环境详情页却可能按兼容规则去另一个环境找同名资产。任务状态已经是 done资源请求还是 404。后来把规则收紧文件在哪个环境解析阅读资产就跟随当前后端地址不再为了兼容旧数据跨环境猜地址。旧环境里已经失效的资源让用户重新上传生成。这轮修完以后我又拿这 5 个文件跑了一遍还是没完全好使。地址已经对了同一个文件重新上传后系统却把之前的错误结果拿了回来。为了避免并发上传同一文件时重复解析项目之前做了去重同一个文件再次上传时会根据稳定标识去找已有结果。去重本身没问题问题是这里把两种情况混在了一起同一时刻的重复请求应该等待正在运行的 producer上一次已经结束用户重新上传应该生成这一次的阅读资产。当时缓存里保存的恰好是前一轮错误结果里面还带着旧contentPackage和旧 assetId。第二次上传速度快得不正常它只是把上一次的错误结果直接拿了回来。于是第三次修复把去重改成 active-only。下面是简化后的两道门不是项目里的真实函数名constphase1awaitwithExclusiveActiveLock(identity,producePhase1);constputawaitputProcessingJobWithRedisCas(phase1.job);if(!put.created)returnput.existing;// 已有 producer 正在运行voidstartNewParse(phase1);// 只有抢占成功者启动后台解析第一道门用独占锁约束本机 phase1 producer第二道门用 Redis CAS 约束多 worker job 创建。两个请求同时看到旧 terminal 状态时只有一个事务能把它换成新的 processing另一个事务冲突后重新读取拿到已经存在的 processing。本地文件缓存也遵守同样的边界。当前 producer 仍在运行时后来的请求可以等待它producer 结束以后新上传不会直接读取上一次完成缓存而是重新生成这次上传对应的阅读资产。这里和普通下载缓存不一样PDF/PPTX 的解析结果里不仅有文字还可能包含跟当前环境、当前生成批次绑定的 assetId。文本内容相同不代表旧资源地址在这次仍然有效。这次把去重改成 active-only是因为旧结果里的资产会失效。如果解析结果使用长期有效的内容寻址资源复用成功的 terminal job 没问题。设计去重 key 时我至少会把租户、环境、解析器版本和输入内容算进去。项目现在这个稳定标识只负责关联一次解析流程并不是文件内容哈希。三次修复后我怎样确认它不是碰巧好了这次来来回回修了三层现象真正问题修改任务已经完成轮询偶发 404多个 worker 各自读取进程内 Mapjob/batch 状态改为 Redis 共享状态正常详情资源仍打不开多环境兼容逻辑把资产带到另一环境资源地址只跟随当前后端环境重新上传仍拿到旧错误结果完成缓存和 terminal job 被错误复用只做 active in-flight 去重新上传重建状态与资产验证不能只看某一次终于打开成功。验证结果放在一起看更直观阶段Worker 证据查询结果修复前生产同一 job 在创建 PID 返回 200落到其他 PID 返回 404done/view_ready与 404 随 worker 变化修复后 Dev两个 PM2 worker日志确认轮询经过不同 PID单 job 与 batch 各 20 次均为 200状态一致修复后生产8 个 PM2 worker双文件 batch 连续 24 次均为200/done/2/2跨连接测试可以从这段命令开始关键是执行后继续按jobId核对服务端 PIDforiin$(seq120);docurl-sS-HConnection: close\-o/dev/null-w$i%{http_code}\n\$BASE_URL/api/files/job/$JOB_IDdone生产双文件任务连续查询 24 次结果均为HTTP 200 status: done completed: 2/2随后再拿最初那组 3 个 PDF、1 个 DOC、1 个 Excel 回去走真实页面。也正是这轮复测才把跨环境资产和旧缓存问题依次翻了出来。直到新上传不再复用旧错误资产五个文件的详情才正常。改一个坏现象最后牵出好几个 Bug这种事在异步系统里太常见了。用户只看到一个 loading背后可能分别卡在任务状态、资源地址和缓存身份上。如果第一次改完看到 404 消失就收工后面两层问题还会继续等着用户。这套修复还不是完整的任务队列Redis 共享的是任务状态后台解析目前仍由收到请求的 Node.js 进程启动。这意味着 worker 正常存活时任意 worker 都能查询进度和结果但如果执行中的 worker 被杀掉或 reloadRedis 里有 processing 状态并不代表另一个 worker 会自动接管剩余解析。要解决执行恢复还需要把解析任务真正交给 BullMQ 这类持久队列设计重试、租约、超时、死信和幂等消费。持久队列也可能重复投递消费者仍要做幂等不能顺手承诺 exactly-once。状态存储和任务执行是两件事不能因为项目已经用了 Redis就默认两件事都解决了。再遇到随机 404我会先查这几件事以后再碰到“创建成功查询却偶发不存在”我不会先盯着 URL 看半天而是按这条线查这个 404 是 Nginx/网关返回的还是业务代码get(id)以后返回的创建和查询是不是落到了不同 PID、容器或实例任务状态放在进程内 Map、本地缓存还是共享存储单节点正常、多节点异常是否正好说明状态边界错了共享状态以后404、503、failed 和 TTL 到期有没有分开修掉状态问题后资源地址和完成缓存会不会继续复用旧结果这次故障最开始看起来像 AI 解析慢后来却一路查到 PM2、Redis、环境隔离和缓存身份。测试环境没问题不一定说明代码没问题。有时只是因为测试环境只有一个节点刚好替你把问题藏起来了。下一篇继续聊端云降级网络断了以后哪些能力应该留在本地哪些请求应该排队哪些操作宁可失败也不能偷偷换一条路执行。