从偶发无字幕到补偿探测链路一次 B 站字幕导入问题的完整收敛过程熟悉我的朋友都知道我搞了一个基于 B 站视频字幕的问答系统项目地址在 rag-bilibili。这个系统三月份就基本完成了最近在持续完善。三月份的时候我遇到过一个问题当时给项目提了一个 issue#4按照文档描述reader 在视频中检测到人声后理论上应当能够读取并提取字幕。但实际测试里情况分成了两类——一部分视频不管怎么跑都拿不到字幕稳定失败另一部分视频有时候能正常拿到有时候返回空列表同一个视频重新跑几次又能恢复。典型的偶发问题而且当时系统刚做完时间紧就先记下来没管它。最近开始集中整理字幕清洗、字幕样本和导入链路这个问题终于躲不过去了我决定把它真正收敛掉。样本实验既然有的视频能拿到字幕、有的拿不到那第一步自然就是搞清楚到底哪些能拿、哪些不能拿有没有规律。我专门拉了一批样本做实验总共看了 40 个视频把 reader 的读取结果和后续重试表现记录下来逐一分析。结果发现这 40 个样本可以分成两类33 个视频 reader 大多数时候能正常拿到字幕7 个视频不管怎么跑都是空。那这 7 个到底是什么情况我仔细去看了这几个视频的页面发现了一个很直接的共同点——它们的播放器右下角都没有字幕按钮。也就是说B 站本身就没有给这些视频提供官方 AI 字幕reader 拿不到是正常的。那另外 33 个呢它们的页面都有字幕按钮字幕能力是开通的。但这里面有一部分出现了首轮读取失败、后续重新读取又恢复成功的情况。像BV1HDo7BhE1u和BV1S1dSBcEVc这两个样本都复现过这种首轮失败、后续恢复的现象。这说明字幕是有的只是首轮没取到。我推测可能是因为 B 站的 AI 字幕需要一定的预热时间或者说 AI 字幕的生成本身存在延迟首轮请求的时候字幕还没准备好后面再读就拿到了。当然这只是推测没有去深挖 B 站内部的生成机制但从现象上看重试确实能解决这个问题。到这里思路就很清晰了有没有字幕按钮可以直接作为判断视频是否支持字幕读取的依据。原来混在一起的读取失败终于被拆成了两类——一类是视频本身不支持字幕拿不到是预期行为另一类是视频有字幕但 reader 偶发没取到值得重试。这个区分一旦建立后面的补偿探测和有限重试也就有了明确依据。方案选型围绕这条补偿链路我实际考虑过几种方案。最容易想到的是 reader 失败后直接无脑重试实现简单后端改动也小但它区分不出真的没有字幕和暂时没读到字幕对本来就不支持字幕的视频也会白等几轮错误提示也依旧含糊。另一种思路是让前端去探测页面再把结果传回后端。这个方案也说得通因为浏览器天然就在页面环境里判断有没有字幕按钮很方便。但我最终没有把这条路走下去因为导入能力本身应该是后端自治的。只要把关键判断依赖到前端状态上后续如果要做接口复用、自动化导入或者批量导入这条链路就会变得别扭。最后落地的是第三种方案正常路径优先直读 reader只有首轮读取失败时后端再触发一次页面探测根据探测结果决定直接失败还是进入有限重试。这样做的好处很明确成功路径不增加额外时延失败路径则多了一层关键判断系统可以给出更有区分度的处理结果。链路收敛最终落地之后导入链路变成了一个比较清晰的分流结构。reader 首轮成功后面继续清洗、分片、向量化和入库reader 首轮为空就补一次页面探测页面没有字幕按钮直接提示当前视频不支持字幕读取页面存在字幕按钮则进入有限重试。这样一来系统不再把所有失败都揉成一种失败。对于用户来说错误提示终于有了真实含义对于后端来说补偿逻辑也终于有了明确边界。后面我又补了一层事务边界收缩和向量脏数据补偿把导入失败后该怎么收尾也一起处理掉整条链路才算真正稳下来。探测耗时实验这套方案落下来之后我又补了一轮专门的探测耗时实验。实验条件尽量保持一致统一使用带凭证的真实页面环境统一timeout-ms8000统一串行执行尽量减少页面状态和运行方式带来的额外干扰。总共抽了 10 个确认存在字幕按钮的视频来测先看单次再看串行。冷启动首轮探测耗时约 7.8 秒紧接着同一视频的热启动探测约 6.5 秒5 次串行时总耗时 35146 ms平均 7029 ms最快 6612 ms最慢 7737 ms扩大到 10 次串行后总耗时 85459 ms平均 8546 ms最快 6825 ms最慢 14027 ms。汇总如下场景总耗时平均耗时最快最慢冷启动单次—~7.8s——热启动单次—~6.5s——5 次串行35,146ms7,029ms6,612ms7,737ms10 次串行85,459ms8,546ms6,825ms14,027ms这个结果很有意思。第一页面探测确实有成本量级大致落在 7 秒左右个别样本会进一步抖高第二首轮冷启动会比后续略慢但差距没有夸张到不可接受第三这个成本主要集中在失败补偿路径上正常导入路径不会承担这部分开销。写到这里我反而觉得很有意思读书时写过无数次控制变量“对照实验”当时总觉得离真实工程很远结果到了这种问题面前还是老老实实得靠这一套把现象拆清楚。子进程输出流阻塞这件事做到这里本来已经差不多了结果在探测链路上又顺手碰到了一个很有意思的问题。页面探测脚本是用 Node 和 Playwright 写的后端是 Java所以整条调用链实际上是 Java 通过ProcessBuilder拉起一个 Node 子进程再由这个子进程执行 Playwright 去探测页面。原始实现里Java 这边的逻辑很直接process.start()然后process.waitFor(...)等进程结束之后再去读取stdout和stderr。这个写法在功能上没有问题正常路径下脚本输出也很小所以一开始看不出什么异常。后面 reviewer 提了一个很到位的点如果子进程输出的内容太多尤其是异常路径下stderr打出较多日志时操作系统管道缓冲区可能被写满。缓冲区一满子进程写输出就会阻塞子进程阻塞之后进程退出就会被拖住父进程这边又卡在waitFor()最终表现出来的现象就是探测超时甚至互相卡住。修法我选得比较保守也比较稳。我没有去改探测脚本的输出协议而是直接在 Java 侧调整了子进程输出的消费方式。新的实现会在process.start()之后立刻启动两个 collector 线程一个持续读取stdout一个持续读取stderr主线程再执行waitFor(...)。这样做之后子进程运行期间产生的输出会被及时消费异常高输出场景下的阻塞风险也就收敛掉了。这个改动本身不大但它提醒我一件很重要的事跨运行时调用从来不只是脚本能不能跑起来这么简单子进程生命周期、输出流消费、异常路径日志、超时之后的收尾这些都属于工程稳定性的一部分。另外提一嘴大概一年前学操作系统的时候课本上讲过进程间通信的几种方式管道pipe、消息队列、共享内存、信号量、Socket 这些。当时觉得这就是一堆概念考试背一背就过去了。作为一个 Java 开发者平时接触的大多已经是封装好的高层协议比如 HTTP、RPC 之类进程间通信这些东西离日常开发确实挺远的。没想到这次做页面探测Java 通过ProcessBuilder拉起 Node 子进程整条链路本质上就是在用管道做进程间通信而我不仅第一次在实战中真正践行了课本里的 IPC 方法还遇到了一个非常经典的操作系统问题——管道缓冲区写满导致写端阻塞读端又卡在waitFor()不去消费双方互相等这不就是课本上教的死锁嘛。跟前面做样本实验时的感受一样。回顾回头看这轮收敛表面上是在修一个 B 站字幕偶发读取失败的问题真正落地时处理的东西远比这个表象要多。前面有样本实验和失败分流后面有页面探测和有限重试再往后还有事务边界、补偿删除以及 Java 和 Node 子进程之间的输出流协作。这样的过程对我来说很有意思因为它没有停留在把功能跑通这一层而是一路把异常路径也一起做实了。功能能跑起来只代表链路可用失败时系统是否知道自己在失败什么、该怎么继续处理、能不能把副作用收干净这些细节补齐之后系统才更像一个真正能长期演进的系统。