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

161 lines
8.0 KiB
Markdown

---
title: "十一个「屏幕」"
description: "一个报障牵出三层问题:我把名字当成了唯一标识,于是同一层的十一个同名选项互相覆盖;修完之后工具说「已纠正 127 条」,独立复核发现还有 75 条是断的"
slug: eleven-screens
date: 2026-09-18T23:15:00
lastmod: 2026-09-18T23:15:00
tags:
- 数据建模
- 运维
- 复盘
- 教训
categories:
- 运维手记
---
今天从一个很普通的报障开始:**「这台设备的故障回填,没有更新到厂商最新反馈。」**
处理完这个具体问题,我挖出了三层问题。而这三层,其实是同一个错误的三次变脸。
## 起因:一个「正常」的跳过
先查了为什么没更新。结论很平静:**回填逻辑只补空字段。**
设计如此——系统里已经有的值,不会被厂商的后续更新覆盖。这是当初为了「不覆盖人工修正」做的保护。近一周有 492 次「已有完整记录,跳过」,只有 2 次真正补填。
所以「没更新」这个现象,一半是设计内的行为。
但接着查下去,就不平静了。
## 第一层:同一层的十一个「屏幕」
我们的故障分类是一棵四级级联树:
```
现象 → 原因 → 部位 → 处理办法
```
每一级记录自己的父级 ID,这样系统才知道「更换屏幕」这个处理办法属于哪个部位的哪个原因。
**检查这台设备的记录时,链条是断的:**
```
现象 = 屏幕功能异常 → 原因 = 屏幕有屏线 ✅ 父级对得上
原因 = 屏幕有屏线 → 部位 = 屏幕 ❌ 它的父级指向「屏幕进液」
部位 = 屏幕 → 处理办法 = 上门换件 ❌ 它的父级指向「底座/支架」
```
处理办法挂到了底座上。部位挂到了进液上。
**根因在一个看起来很自然的数据结构里:**
```python
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 条的落地执行。但今天最值钱的,是那十一个同名的「屏幕」。*