#从偶发无字幕到补偿探测链路:一次 B 站字幕导入问题的完整收敛过程

从偶发无字幕到补偿探测链路:一次 B 站字幕导入问题的完整收敛过程

熟悉我的朋友都知道,我搞了一个基于 B 站视频字幕的问答系统,项目地址在 rag-bilibili。这个系统三月份就基本完成了,最近在持续完善。

三月份的时候我遇到过一个问题,当时给项目提了一个 issue(#4):按照文档描述,reader 在视频中检测到人声后,理论上应当能够读取并提取字幕。但实际测试里,情况分成了两类------一部分视频,不管怎么跑都拿不到字幕,稳定失败;另一部分视频,有时候能正常拿到,有时候返回空列表,同一个视频重新跑几次又能恢复。典型的偶发问题,而且当时系统刚做完,时间紧,就先记下来没管它。

最近开始集中整理字幕清洗、字幕样本和导入链路,这个问题终于躲不过去了,我决定把它真正收敛掉。

样本实验

既然有的视频能拿到字幕、有的拿不到,那第一步自然就是搞清楚:到底哪些能拿、哪些不能拿,有没有规律。我专门拉了一批样本做实验,总共看了 40 个视频,把 reader 的读取结果和后续重试表现记录下来,逐一分析。

结果发现这 40 个样本可以分成两类:33 个视频 reader 大多数时候能正常拿到字幕,7 个视频不管怎么跑都是空。那这 7 个到底是什么情况?我仔细去看了这几个视频的页面,发现了一个很直接的共同点------它们的播放器右下角都没有"字幕"按钮。也就是说,B 站本身就没有给这些视频提供官方 AI 字幕,reader 拿不到是正常的。

那另外 33 个呢?它们的页面都有字幕按钮,字幕能力是开通的。但这里面有一部分出现了首轮读取失败、后续重新读取又恢复成功的情况。像 BV1HDo7BhE1uBV1S1dSBcEVc 这两个样本,都复现过这种"首轮失败、后续恢复"的现象。这说明字幕是有的,只是首轮没取到。我推测可能是因为 B 站的 AI 字幕需要一定的预热时间,或者说 AI 字幕的生成本身存在延迟,首轮请求的时候字幕还没准备好,后面再读就拿到了。当然这只是推测,没有去深挖 B 站内部的生成机制,但从现象上看,重试确实能解决这个问题。

到这里思路就很清晰了:有没有字幕按钮,可以直接作为判断视频是否支持字幕读取的依据。 原来混在一起的"读取失败"终于被拆成了两类------一类是视频本身不支持字幕,拿不到是预期行为;另一类是视频有字幕,但 reader 偶发没取到,值得重试。这个区分一旦建立,后面的补偿探测和有限重试也就有了明确依据。

方案选型

围绕这条补偿链路,我实际考虑过几种方案。

最容易想到的是 reader 失败后直接无脑重试,实现简单,后端改动也小,但它区分不出"真的没有字幕"和"暂时没读到字幕",对本来就不支持字幕的视频也会白等几轮,错误提示也依旧含糊。

另一种思路是让前端去探测页面,再把结果传回后端。这个方案也说得通,因为浏览器天然就在页面环境里,判断有没有字幕按钮很方便。但我最终没有把这条路走下去,因为导入能力本身应该是后端自治的。只要把关键判断依赖到前端状态上,后续如果要做接口复用、自动化导入或者批量导入,这条链路就会变得别扭。

最后落地的是第三种方案:正常路径优先直读 reader,只有首轮读取失败时,后端再触发一次页面探测,根据探测结果决定直接失败还是进入有限重试。这样做的好处很明确,成功路径不增加额外时延,失败路径则多了一层关键判断,系统可以给出更有区分度的处理结果。

链路收敛

最终落地之后,导入链路变成了一个比较清晰的分流结构。reader 首轮成功,后面继续清洗、分片、向量化和入库;reader 首轮为空,就补一次页面探测;页面没有字幕按钮,直接提示当前视频不支持字幕读取;页面存在字幕按钮,则进入有限重试。这样一来,系统不再把所有失败都揉成一种失败。对于用户来说,错误提示终于有了真实含义;对于后端来说,补偿逻辑也终于有了明确边界。后面我又补了一层事务边界收缩和向量脏数据补偿,把"导入失败后该怎么收尾"也一起处理掉,整条链路才算真正稳下来。

探测耗时实验

这套方案落下来之后,我又补了一轮专门的探测耗时实验。实验条件尽量保持一致:统一使用带凭证的真实页面环境,统一 timeout-ms=8000,统一串行执行,尽量减少页面状态和运行方式带来的额外干扰。总共抽了 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,146ms 7,029ms 6,612ms 7,737ms
10 次串行 85,459ms 8,546ms 6,825ms 14,027ms

这个结果很有意思。第一,页面探测确实有成本,量级大致落在 7 秒左右,个别样本会进一步抖高;第二,首轮冷启动会比后续略慢,但差距没有夸张到不可接受;第三,这个成本主要集中在失败补偿路径上,正常导入路径不会承担这部分开销。写到这里我反而觉得很有意思,读书时写过无数次"控制变量""对照实验",当时总觉得离真实工程很远,结果到了这种问题面前,还是老老实实得靠这一套把现象拆清楚。

子进程输出流阻塞

这件事做到这里,本来已经差不多了,结果在探测链路上又顺手碰到了一个很有意思的问题。

页面探测脚本是用 Node 和 Playwright 写的,后端是 Java,所以整条调用链实际上是 Java 通过 ProcessBuilder 拉起一个 Node 子进程,再由这个子进程执行 Playwright 去探测页面。原始实现里,Java 这边的逻辑很直接:process.start(),然后 process.waitFor(...),等进程结束之后再去读取 stdoutstderr。这个写法在功能上没有问题,正常路径下脚本输出也很小,所以一开始看不出什么异常。

后面 reviewer 提了一个很到位的点:如果子进程输出的内容太多,尤其是异常路径下 stderr 打出较多日志时,操作系统管道缓冲区可能被写满。缓冲区一满,子进程写输出就会阻塞;子进程阻塞之后,进程退出就会被拖住;父进程这边又卡在 waitFor(),最终表现出来的现象就是探测超时,甚至互相卡住。

修法我选得比较保守,也比较稳。我没有去改探测脚本的输出协议,而是直接在 Java 侧调整了子进程输出的消费方式。新的实现会在 process.start() 之后立刻启动两个 collector 线程,一个持续读取 stdout,一个持续读取 stderr,主线程再执行 waitFor(...)。这样做之后,子进程运行期间产生的输出会被及时消费,异常高输出场景下的阻塞风险也就收敛掉了。这个改动本身不大,但它提醒我一件很重要的事:跨运行时调用从来不只是脚本能不能跑起来这么简单,子进程生命周期、输出流消费、异常路径日志、超时之后的收尾,这些都属于工程稳定性的一部分。

另外提一嘴,大概一年前学操作系统的时候,课本上讲过进程间通信的几种方式:管道(pipe)、消息队列、共享内存、信号量、Socket 这些。当时觉得这就是一堆概念,考试背一背就过去了。作为一个 Java 开发者,平时接触的大多已经是封装好的高层协议,比如 HTTP、RPC 之类,进程间通信这些东西离日常开发确实挺远的。没想到这次做页面探测,Java 通过 ProcessBuilder 拉起 Node 子进程,整条链路本质上就是在用管道做进程间通信,而我不仅第一次在实战中真正践行了课本里的 IPC 方法,还遇到了一个非常经典的操作系统问题------管道缓冲区写满导致写端阻塞,读端又卡在 waitFor() 不去消费,双方互相等,这不就是课本上教的死锁嘛。跟前面做样本实验时的感受一样。

回顾

回头看这轮收敛,表面上是在修一个 B 站字幕偶发读取失败的问题,真正落地时处理的东西远比这个表象要多。前面有样本实验和失败分流,后面有页面探测和有限重试,再往后还有事务边界、补偿删除,以及 Java 和 Node 子进程之间的输出流协作。

这样的过程对我来说很有意思,因为它没有停留在"把功能跑通"这一层,而是一路把异常路径也一起做实了。功能能跑起来,只代表链路可用;失败时系统是否知道自己在失败什么、该怎么继续处理、能不能把副作用收干净,这些细节补齐之后,系统才更像一个真正能长期演进的系统。

相关推荐
简简单单就是我_hehe1 小时前
后端链路追踪局部采集和全量采集配置说明
java·开发语言
存在的五月雨2 小时前
SpringBoot 基于数据库的动态定时任务管理器实现方案
java·spring boot
IT_陈寒2 小时前
JavaScript里这个隐式类型转换的坑,我终于爬出来了
前端·人工智能·后端
掘金者阿豪2 小时前
Django接金仓数据库:我踩过的坑和填坑指南
后端
椰羊~王小美2 小时前
@RequestMapping注解的各个属性作用
java
_风满楼2 小时前
HTTP 请求的五种传参方式
前端·javascript·后端
码事漫谈3 小时前
为什么 token 计费规则里,输出比输入贵那么多
后端
Go_error3 小时前
Go database/sql 基于临时 channel 传递连接
后端·go
Yeh2020583 小时前
request与response笔记
java·前端·笔记