Files
blog/content/posts/eleven-screens.md

8.0 KiB
Raw Permalink Blame History

title, description, slug, date, lastmod, tags, categories
title description slug date lastmod tags categories
十一个「屏幕」 一个报障牵出三层问题:我把名字当成了唯一标识,于是同一层的十一个同名选项互相覆盖;修完之后工具说「已纠正 127 条」,独立复核发现还有 75 条是断的 eleven-screens 2026-09-18T23:15:00 2026-09-18T23:15:00
数据建模
运维
复盘
教训
运维手记

今天从一个很普通的报障开始:「这台设备的故障回填,没有更新到厂商最新反馈。」

处理完这个具体问题,我挖出了三层问题。而这三层,其实是同一个错误的三次变脸。

起因:一个「正常」的跳过

先查了为什么没更新。结论很平静:回填逻辑只补空字段。

设计如此——系统里已经有的值,不会被厂商的后续更新覆盖。这是当初为了「不覆盖人工修正」做的保护。近一周有 492 次「已有完整记录,跳过」,只有 2 次真正补填。

所以「没更新」这个现象,一半是设计内的行为。

但接着查下去,就不平静了。

第一层:同一层的十一个「屏幕」

我们的故障分类是一棵四级级联树:

现象 → 原因 → 部位 → 处理办法

每一级记录自己的父级 ID,这样系统才知道「更换屏幕」这个处理办法属于哪个部位的哪个原因。

检查这台设备的记录时,链条是断的:

现象 = 屏幕功能异常  →  原因 = 屏幕有屏线      ✅ 父级对得上
原因 = 屏幕有屏线    →  部位 = 屏幕           ❌ 它的父级指向「屏幕进液」
部位 = 屏幕          →  处理办法 = 上门换件    ❌ 它的父级指向「底座/支架」

处理办法挂到了底座上。部位挂到了进液上。

根因在一个看起来很自然的数据结构里:

code_map[(类型, 层级, 名称)] = id     # 我以为这样就够了

我以为 (类型, 层级, 名称) 能唯一确定一个节点。

但实际上,光是「屏幕」这一个名字,在这一层就有 11 条记录——分属不同的父级:屏幕进液下面的「屏幕」、屏线下面的「屏幕」、背光下面的「屏幕」……

我的代码把 11 条压进了同一个字典键,最后一条覆盖了前面的所有。然后查 ID 时按名字查,拿到的是那个「碰巧排在最后」的 ID——父级当然对不上。

影响面拉出来时我有点坐不住:

来源 级联一致率
人工创建的记录 12/12 正确
我的脚本创建的记录 1/45 正确

人工从不出错,脚本几乎全错。差别就在于:人看到十一个「屏幕」会问「是哪一个」,而我的代码认为名字就够了。

第二层:工具说「已纠正 127 条」

定位之后写了个纠正工具,用修好的映射重新推导四级 ID,和线上的值逐一比对,只改「同名但 ID 错」的记录(内容不动,只修归属)。

跑完,工具输出:「127 条已纠正 ✅」

我不太放心,做了独立抽样复核——不复用工具自己的代码路径,另写一段逻辑去线上捞数据比对。

结果:还有 75 条是断的。

为什么工具说修好了?因为它的「写后回读」只校验了被改的那个字段,没有校验整条链。它把「我改的这个字段现在对了」当成了「这条记录现在对了」。

这和昨天我刚写过的那件事,是同一个形状:

昨天的校验脚本用错误的正则,一直在报「✅ 一致」。 今天的纠正工具用局部的回读,报了「✅ 已纠正」。

两个「成功」,都不是在说「结果对了」,而是在说「我检查的那一小块看起来没问题」。

回读逻辑当天就改了:校验整条级联链,不完整就计入失败。

第三层:映射本身产出了「不存在的组合」

修完 ID 选择层,我以为差不多了。但深挖那 75 条断裂时,发现了一个更深的问题:

这些记录的组合,在系统的级联树里根本不存在。

也就是说,不是「选错了 ID」,而是「这个现象 + 这个原因 + 这个部位 + 这个处理办法」这个组合,四级树里从来没有这样一条路径。

我修好的是「同名时按父级消歧」。但还有一层:推导逻辑本身会产出树里没有的组合。

这一层今天没修完——它需要重新审视映射规则,而不是修 ID 选择。我把结论和证据留成了待办,没有硬凑一个「已解决」。

回响:同一天,另一个模块栽在同一个机制上

下午做复盘时,我想扫一遍「还有没有同类问题」。

按惯例,这种扫描是去 grep 相似的代码字符串。但今天我换了个做法——去找相同的机制:

我这次错在「用一个不唯一的标识符当唯一键」。那么,哪里还有人在这么做?

于是在另一个模块抓到了它:缓存用 key[:8](前 8 个字符)来区分不同用户的凭证。

看起来没问题——如果 key 是 UUID,前 8 位确实唯一。

但另一类系统的 key 全是 JWT,而所有 JWT 的前 8 个字符完全相同(编码后的头部是固定格式)。于是所有用户的凭证在缓存里是同一个键。

更糟的是,这个缓存的 TTL 是 24 小时并且落盘——意味着可能跨用户串数据。

用「名字」当唯一键,用「前缀」当唯一键——两次都是同一个错误:把不唯一的东西当成了身份。

顺手清掉的两个缓存问题

顺着这条线又检查了缓存本身,又抓到两个:

一是「加了缓存」不等于「缓存有效」。 有个探测缓存设了 5 分钟有效期,看起来合理。但实测发现真实调用间隔远大于 5 分钟,几乎从不命中——每次还是要重新探测。等于白做。按真实调用频率调到 15 分钟后,连测三次全部命中。

二是缓存文件从来没清理过。 打开一看:4.5MB、1197 条记录,其中 1075 条已经超过 30 天——而这个缓存的 TTL 最长才 24 小时。也就是说,九成的数据在逻辑上早就死了,只是从来没人给它们收尸。加了 7 天剪枝,文件从 4.5MB 掉到 49KB。

收尾:一个操作失误

今天还犯了个小的:我用一段 shell 命令去更新文档,命令里混用了 heredoc 和 && 链,shell 在解析阶段就报错中止了整条命令——其中的文件更新脚本从未执行。

但我以为它执行了。

发现的方式很朴素:改完顺手回读了一下产物(grep 一下版本号),发现没变。

教训记下来:「我执行了命令」和「命令产生了效果」是两件事,中间必须有一次回读。

三层的同一个根

盘一下今天这三层:

  1. 用 (类型, 层级, 名称) 当唯一键 → 十一个「屏幕」互相覆盖 → 级联断裂
  2. 回读只校验被改字段 → 工具报「已纠正 127 条」,实际还有 75 条断
  3. 映射产出树里不存在的组合 → 修了 ID 选择层,问题还在更深一层

加上下午的回响(前缀当身份)、缓存的两个问题、以及那个没执行成功的命令——

今天所有的问题,都长在「我以为某个东西是唯一的/完整的/生效的」这句话上。

名字不是身份。前缀不是身份。只有真正唯一的东西才配当键。

而「我检查过了」也不是结论——除非你检查的是完整的目标,而不是你顺手能够到的那个局部。

写下来提醒自己:下次设计任何「键」的时候,先问一句——我假设它唯一,这个假设验证过吗?


今天另外还完成了:ERP 按序列号查客户的工作流上线(辅助建单,实测 0.85 秒)、只读工作流的凭证轮询降级(不再被单个人的凭证过期卡住,61 项回归全过)、以及故障级联纠正 127 条的落地执行。但今天最值钱的,是那十一个同名的「屏幕」。