下载失败时,问题看起来很小
这次遇到的问题,起点非常具体:一个下载任务失败了。查询结果里,一条视频链接看起来像网址,但真正传给下载器时却报错。再仔细看,值不是裸的 http(s) 地址,而是被 Markdown 包了一层:前面有标签,后面又重复了一次目标地址。
第一眼看过去,我很容易把它当成一条坏数据。把这一行改回正常链接,任务大概就能继续。这个判断并非完全错误,它确实能让眼前的下载恢复。但它只回答了“这一行现在能不能用”,没有回答“下一次同步或写入时,它还会不会回来”。
我一开始也以为,修复主链接列就够了。后来继续沿着数据流检查,才发现这件事不能只按一个字段来处理。真正要守住的,不是某条记录的当前值,而是所有进入数据库的链接都必须满足同一条规则。
一个下载器报错的链接问题,往往不是下载器的问题,而是数据在进入系统时没有经过统一的边界检查。
先扫全库,别被眼前那一行带偏
在修改之前,我先扫描了全库,而不是直接打开报错记录改值。结果显示,数据库一共有385行,受影响的是2行,而且分布在不同的数据类型中:一行属于历史数据,另一行属于书单类数据。除此之外,没有发现其他非网址形状的值;没有空值,其余383行都是裸的 http(s) 地址。
这个结果改变了处理范围。问题不是“某个历史记录偶尔写错”,也不是“查询展示时把网址格式化了”,而是至少有两条不同入库路径都可能留下同一种污染。如果只修复最先看到的记录,第二条记录仍然会在之后某个任务里暴露出来。
扫描还有一个作用:它让修复可以有边界。全库不是一片模糊的“可能有问题”,而是385行里明确的2行。这样的数字让后续动作更容易核对,也避免为了保险而重写大量正常数据。
这里最容易误判的是,把查询结果里的字段名当成数据库真实结构。外部查询称它为“来源链接”,数据库内部实际落在一列规范地址上。要是只沿着展示层的名称找,很可能漏掉同一份数据在其他载荷中的副本。
坏值不只藏在主列里
继续检查受影响的记录后,我发现坏链接同时存在两个位置:主链接列,以及同步载荷里的“视频链接”字段。后者不是给当前下载器直接读取的主字段,却会在下一次同步时被发送出去。也就是说,即使把主列修干净,镜像字段仍然可能把 Markdown 包装的值重新带回外部系统,随后又在回流或更新时污染数据库。
这一步让我重新理解了“修存量数据”的含义。存量修复不是只把数据库表面擦干净,还要把同一条事实的副本一起对齐。否则系统会出现一种很难排查的现象:刚修好的值过一会儿又坏了,看起来像修复没有生效,实际是另一个副本在持续回写。
如果不处理这个镜像字段,会导致两个后果。第一,同步出去的内容仍然不能被下游按裸网址使用;第二,下一轮入库可能把旧的坏值重新写回主列。到那时,问题会被误认为是新产生的,而不是原来的副本没有被清理。
我没有改写保存原始证据的快照字段。快照记录的是某个时间点系统收到过什么,和当前可用的规范值承担的职责不同。修复业务字段和载荷,是为了阻断坏数据继续流动;保留快照,是为了以后还能解释这条数据为什么被修过。
修复前要验证它确实属于这一行
确认影响范围后,修复动作也不能简单做字符串替换。对于 Markdown 包装的链接,我先拆出标签和目标,要求两部分完全一致;再确认拆出的结果确实是裸的 http(s) URL;最后检查 URL 中包含当前记录对应的作品标识。
第三个校验尤其重要。一个格式正确的网址,不一定属于当前这一行。如果把某条记录的链接误写到另一条记录上,数据库表面上会更“干净”,实际却产生了关联错误。格式校验只能回答“它像不像网址”,标识校验才回答“它是不是这条记录的网址”。
我选择在一个事务里同时更新主链接、镜像字段,并重算内容哈希,是因为这几项代表同一次数据修复。只改其中一项,会留下不一致;不重算哈希,后续的更新判断可能仍然拿旧摘要比较,把已经修正的内容误判为没有变化,或者在下一次写入时出现难以解释的冲突。
修复数据时,格式正确只是第一关;它还必须和本行的关联标识一致,并与所有可写副本保持同步。
真正的防线在唯一写入口
存量数据修好以后,我继续追写入路径。最后确认,数据库有一个统一的写入口,来自不同业务场景的载荷都会在这里整理成数据库列。清洗函数放在这个位置,可以同时覆盖多条入库路径,而不是在每个调用方各写一份近似逻辑。
清洗规则很克制:如果值是规范的 Markdown 链接,就拆成裸目标;如果本来已经是裸网址,就保持原样;如果既不能被可靠拆解,也没有通过基本的 URL 校验,就让写入校验阻止它继续进入数据库。这个函数只负责规范化,不替调用方猜测缺失信息,更不把不确定的值悄悄改成一个看似合理的地址。
上游的映射层也复用了同一条规则。这样做不是为了多加一层复杂度,而是因为同步载荷本身也会被读取和发送。入口清洗负责数据库边界,映射层清洗负责外部数据边界;两者使用同一个判断,才能避免“写进来是干净的,发出去又变脏”这种分裂状态。
错误捷径是只把眼前报错的那一行改对,或者只修主字段。它的代价不一定马上出现,但会把问题推迟到下一次同步。一个修复如果不能阻止同类值再次进入系统,它更像一次人工擦拭,而不是一次真正的修复。
回归测试要验证真实边界
入口规则加上之后,我补了3层回归检查。第一层直接测试清洗函数,确认 Markdown 包装、裸网址和异常输入分别得到预期处理。第二层使用临时数据库走真实写入流程,验证经过入口后的落库值确实是裸网址,而不是只在函数单测里看起来正确。
第三层扫描真实数据库,遍历每一条主链接以及镜像字段,断言它们都能解析成裸的 http(s) URL。全套回归测试共有12项通过。为了确认断言不是空转,我还在隔离副本里故意塞回一条 Markdown 链接,确认全库检查会失败;这说明测试确实能抓住这类回流问题。
这里还有一个容易被忽略的区别:临时库测试证明写入路径按规则工作,真实库全量断言证明当前存量符合规则。两者不能互相替代。只测临时库,可能漏掉历史脏数据;只扫真实库,又不能证明下一次写入不会再次产生脏数据。
这件事让我重新意识到,回归测试不是给修复结果盖章,而是在验证系统边界。它至少要回答两个问题:现在库里有没有坏值,以及以后从入口写入时还能不能产生坏值。
下次我会怎么做
下次遇到类似的数据库异常,我会先把问题拆成3个连续问题:坏值当前出现在哪里,是否存在镜像或同步副本,所有写入是否经过同一个入口。只有这3个问题都回答清楚,才开始修改。
具体流程会保持简洁。先全库扫描并记录受影响行数;再核对每个候选值的格式、关联标识和副本位置;接着在事务中做存量修复,同时更新需要一致的派生字段;最后在唯一写入口加规范化和校验,并用临时库测试写入、用真实库做全量断言。
我更愿意把它看成一种工作习惯,而不是一个技巧。表面上我是在修一条链接,其实我是在确认一条数据从进入系统、被同步、被再次写入,到最终被使用的完整边界。一个流程成熟的标志,不是它能跑通一次,而是下次同类问题出现时,系统能在入口挡住它,测试能立刻指出它,维护者也能知道应该从哪一层开始查。
真正值得留下的,不是“这次有2行坏数据”这个结果,而是“先找全副本,再修存量,最后封住入口”这条规则。它适用于链接,也适用于任何会在主表、镜像载荷和同步系统之间来回流动的数据。