它报告了成功,可实际上什么都没变
一批”看起来没问题”的任务
我跑着一条转换流水线,处理一个 582 个文件的视频库。库在 NAS 上,通过第三方 SMB 客户端挂载成 Windows 盘符(就是那种把共享目录呈现成普通 Z:\ 盘的东西)。文件有大有小,只有一个写入者。
流水线做的都是些显而易见的事:把源文件暂存到本地、编码、验证输出、上传到原文件旁边、替换原文件。我想到要检查的每一步,它都记了成功。
然后我开始审计结果,而不是审计日志。在 30 个被报告为”已完成”的文件里,有一个根本没有变——共享盘上还是原文件。没有报错、没有警告、没有失败步骤。日志写着 ok。
这就是本文要讲的失败模式:不是崩溃,不是文件损坏,而是一个报告了它并未达成之结果的系统。如果你的流水线用”自己的日志”来定义成功,那你对自己的磁盘上到底有什么,一无所知。
谎言一:写入提前停止,却返回成功
第一个最吓人,因为在调用的那一刻一切看起来都对。
FAIL read-back mismatch
local = 3b7cd25d1de1 403,448,100 bytes
remote = 6a7785351031 12,582,912 bytes
一次 403 MB 输出文件的上传写入了 12.5 MB 就返回了。没有异常、没有非零退出码、没有短写返回码。客户端自己的错误——一个超时提示——是几分钟后异步弹出的,那时我的代码早就往下走了,成功日志都打完了。
这就是为什么在这类存储上,一个”信任 API 返回”的写入成功检查一文不值。写入调用回答的是另一个问题(“我把数据交给缓存了吗?”),而不是你问的那个(“对面那个文件对吗?”)。
修法很无聊但很彻底:把你本地写出的东西做哈希,再把共享盘上的文件读回来做哈希,然后比对。不一致就重传——必要时两次,带退避。
# 把 src 上传为 dst 之后
if hash_file(src) != hash_file(_reread(dst)):
retry_upload()
注意这个代价是故意的:它要再从网络上把文件读一遍。对一个 400 MB 的文件来说是几秒——相对一次 25 分钟的编码属于舍入误差。而换来的是:不必接受一个”有某个比例的文件悄悄不是你以为的样子”的库。
谎言二:重命名没有落地,元数据却说落地了
第二个藏在一个你无条件信任的文件系统原语后面:os.replace()——原子的、同一文件系统内的、不可能做一半的。只不过这里的”文件系统”其实是一个自带元数据缓存的网络客户端。
当时的顺序是:上传 show.mp4.new.mp4、校验哈希、os.replace() 覆盖 show.mp4、然后探测结果确认。探测说 HEVC,大小看着对,日志写了 ok 1085 MB -> 478 MB。
共享盘上那个文件是原封未动的原文件:H.264、921,950,850 字节。替换根本没被应用;客户端为该路径返回了缓存的属性,于是 getsize() 和 mtime 兴高采烈地描述着一个已经不存在的文件。
唯一能在撒谎的元数据缓存下活下来的检查,是把字节读回来:
os.replace(tmp, dst) # 声称
if hash_file(dst) != hash_file(local): # 证据
raise VerificationError(dst)
加上这一步之后,同一次审计在原本 30 个里找出 1 个失败的规模上,找出了 0 个——更重要的是,今后任何静默失败都会变成响亮的失败,因为”目标处的字节和我产出的字节不一致”不是缓存能造假的东西。
谎言三:一个和坏拷贝达成一致的字节数检查
第三个最隐蔽,因为它骗过的是验证本身,而不只是操作。
把源文件从共享盘暂存到本地临时目录是一次拷贝。自然的检查是”字节数对不对?“——所以流水线比了字节数。它们一致。
然后在一次无关的排查里,同一个源文件解码失败了(Error splitting the input into NAL units),我就去找一个并不存在的坏文件。那个文件是好的:921,950,850 字节、头部健康、前 60 秒零错误解码。是那份拷贝被以某种保持长度不变的方式截断了,或者那次读取悄悄读短了、又被补齐了。
长度不是校验和。一次拷贝只有在”你读到的内容的哈希”与”共享盘上那份的哈希”一致时才算被验证——这要么意味着读两遍,要么意味着读完后算哈希、再和另一时刻取的哈希比对。
让事情更糟的那些度量陷阱
追这三个问题的过程中,我的质量检查也在撒谎,值得点名,因为它们并不只属于网络存储:
- 解码测试会在错误的画面上通过。
ffmpeg -v error -i out.mp4 -f null -在一个带着 106 帧错误时间戳画面的文件上什么都没打印。“没有报错”只意味着”没有报错”,不意味着”正确”。 - SSIM 在平坦帧上没有意义。 两帧几乎全黑也能算出 0.25,而它们在视觉上完全相同。任何”单帧过低即判缺陷”的阈值都会拒绝好编码;任何”整体忽略这些帧”的做法又会漏掉真缺陷。
- 对 open GOP 的源做输入 seek,会解出错误的画面。 把一个文件和它自己比对——一侧从中间 seek 进去解码、另一侧顺序解码——测出最低 SSIM 0.25。一个完全由我自己的比对工具制造的”系统性缺陷”假象。
- 流拷贝不是无损切片。 用
-ss ... -c copy从带 B 帧的 H.264 文件里切出 60 秒,本该 1500 帧却只得到 1270 帧。拿来做拖拽预览可以;拿来做测试夹具是致命的。 - 两个并发任务共用临时目录,会互相覆盖中间产物。 我的解码裸帧文件用了固定文件名,于是两个同时运行的闸门比的是两个不同的视频,报出”100% 的帧低于阈值”。现在每一条临时路径都带上进程 id。
纪律
这些都不需要什么聪明办法。它需要一条规则:一个步骤算完成,是在你独立观察到了结果之后,而不是在工具报告了结果之时。
落到一条会往共享盘写东西的流水线上:
- 源文件读两遍。 暂存、哈希、再抓一次共享盘的读数做哈希、比对。长度不是证据。
- 替换任何东西之前,先给上传做哈希。
- 替换之后,给目标做哈希。 缓存的元数据造不了假字节。
- 验证内容,而不只是验证容器——比画面,不比帧率。
- 让每一次失败都响亮。 一个失败得安全的闸门,代价是白烧一次编码;一个放行了坏文件的闸门,代价是整个库和你对它的信任。
- 不要把写者并行化。 三个任务共用一块共享盘和一个临时目录,制造出了短写、超时和互相污染的比较。当我降到单个写者,错误率归零。
我最在意的审计行不是”0 errors”,而是:
reported=30 landed=29 not-landed=0 wrong-codec=1 gone=0
那里的每一个数字都是读目标读出来的,不是信任日志得来的。