用体积和类名计数做发布门禁:一次"能打开但全错"的事故复盘
这次事故的结局是线上界面全部退回原始样式,持续了大概半小时。但真正值得记的不是故障本身,而是我用来"修复"的那一步,让问题从一个明显故障变成了一个隐蔽故障。
时间线
18:41 推了一个版本。加载白屏。定位到 JS 里第 2819 行有语法错误——一个多余的括号。
18:46 为了尽快恢复"能打开",我把 JS 换成了安装包里自带的那一版。语法没问题,应用正常启动,页面正常渲染,看起来修好了。
18:50 收到反馈:阵容、仓库、商城的样式全变了。
原因:安装包里自带的那版 JS 是另一套代码——一个功能精简的旧分支,体积只有正常版本的三分之一。它里面根本没有 ui-sec、ui-card 这些类名,而外部有一份 22KB 的 CSS 专门给这些类名做皮肤。类名不存在,CSS 全部落空,界面自然回原始样式。
语法检查只能证明"这不是坏文件",不能证明"这是对的文件"。
为什么常规手段没拦住
node --check通过 → 因为那确实是一份合法 JS- 应用能启动 → 因为它确实能跑
- 页面能渲染 → 因为它确实渲染了,只是渲染的不是我的界面
三个检查全部通过,全部没用。
门禁应该盯结构,不盯"能不能跑"
出事后我把发布前的检查改成下面几项,全部是几秒钟内能跑完的廉价检查:
1. 语法
// 用 child_process 跑,不 eval —— eval 会真的执行代码
execFileSync('node', ['--check', file]);
2. 体积阈值
我的主 JS 正常在 350KB 上下。精简版是 103KB。所以门禁里写:
size = os.path.getsize(js)
if size < 350 * 1024:
fail("game.js 只有 %d B,低于 350KB —— 极可能是被替换成了精简版" % size)
阈值不是猜的,是取正常版本的体积再打个折。它不精确,但足够区分"我的代码"和"别人的代码"。
3. 结构指纹
比体积更可靠的是数关键标识符出现的次数:
for token in ("ui-sec", "ui-card", "team-presets"):
n = content.count(token)
if n == 0:
fail("缺少 %s —— 这份文件不是当前分支的产物" % token)
这三项在正常版本里都有几十次,在被误替换的那个版本里全是 0。
关键点:指纹要选"只在正确版本里存在"的东西。 选 function、var 这种到处都有的词没有意义;选具体到你这套 UI 的类名才有区分度。
4. 文件大小对不上也要拦
打包流程里最容易出的事是"根本没打进去"。所以在构建脚本末尾加一条:源文件和目标文件的字节数必须完全相等,不等就退出。
事故的真正教训
不是"要写好代码",而是:
救火的时候,人会本能地选"最快能让它动起来"的方案。这时候如果没有机械化的门禁,你一定会把 A 故障换成 B 故障。
我当时的选择在紧急情况下是合理的——线上白屏,任何能启动的版本都比白屏强。但正因为知道自己在那种状态下会做什么,才需要把检查写在脚本里,让它在人判断力最差的时候强制生效。
所以门禁的价值不在于"发现 bug",而在于在你最想抄近路的那一刻挡住你。
顺带一条:只留一个能跑的版本
那次之后我还做了一件事——把发布目录里所有历史包全部移到归档目录,只留当前线上那一个。
理由很直接:目录里堆着五个版本,救火时随手拿了哪个,自己都不一定记得。版本一多,快就等于错。
现在我的发布目录长这样:
deploy/updates/
├── pack-current.zip # 只有这一个
├── manifest.json
└── _archive-20261026/ # 历史包都在这儿,不在发布路径上
小结
检查项写成三个层次:
| 层次 | 检查 | 能拦住什么 |
|---|---|---|
| 语法 | node --check | 坏文件 |
| 体积 | 字节数阈值 | 被替换成别的版本 |
| 结构 | 关键标识符计数 | 被替换成同语言的其他分支 |
第三层才是这次真正缺的那一环。