← 返回首页
事故复盘 · 工程

七个把系统搞挂的夜晚,
和它们各自的根因

这些事故没有一件是「模型不够聪明」造成的。全部是有人在动手之前,少问了那么一句: 这条路径还有谁在用?这个报错被谁吞了?我凭什么相信它说做完了?

2026-11-04约 15 分钟事故复盘 / 工程
目录
  1. 一、两个工单拿到同一个号
  2. 二、锁死的那个晚上
  3. 三、我给「安全」加的那道墙,把登录全堵死了
  4. 四、一句「build 通过」
  5. 五、静默的 TypeError
  6. 六、一个斜杠
  7. 七、我把服务器改错了
  8. 还有几个没上台面的

写这篇不是为了记功,是因为我自己忘性大。每次事故过去两周,细节就开始模糊, 只剩一句「那次好像是因为死锁」。而模糊的教训等于没教训——下次还会踩, 甚至踩得更有信心,因为「我记得这个坑我处理过」。

所以我把七件事按当时的样子写出来:先看到什么症状、往哪个方向猜错了、最后根因到底在哪、 事后加了什么约束。有几件事根因在代码里,有几件事根因在人身上。 后者更麻烦,也更值得写。

一、两个工单拿到同一个号

症状很不起眼:任务列表里偶尔出现两条编号相同的记录。不是天天有,大概几百次派单里撞一次。 第一次看到的时候我以为是显示层面的问题,刷新一下就好了。刷新没好,才意识到是数据库里真的重复了。

当时的取号逻辑朴素到可笑:SELECT MAX(seq) FROM task_event WHERE task_code = ?, 加一,插进去。单人手工测试永远正常。但只要两个执行体几乎同时给同一个任务追加事件, 它们会读到同一个 MAX,算出同一个新号,然后一起往表里插。 谁先提交谁成功,后到的那个——如果约束不够严——就变成一条编号重复的历史记录。

我第一反应是加锁:SELECT ... FOR UPDATE。这确实解决了重复。 但它带来了第二个坑,而且是更隐蔽的那种。

二、锁死的那个晚上

加锁之后,重复编号消失了,换成了偶发的死锁。日志里是一句含糊的 Deadlock found when trying to get lock; try restarting transaction, 两条事务互指对方。我盯着它看了很久,因为逻辑上想不通:两个不同任务的请求,为什么要互相等?

打印出各自的锁对象才看明白。我用的是「扫描式」加锁——为了拿到 MAX,查询范围覆盖了一批记录, 而 MySQL 的 next-key lock 会把间隙一起锁上。任务 A 的请求锁住了自己那段记录和后面的空隙, 任务 B 的请求也锁住了它那段,两个请求的加锁顺序恰好相反,于是互相等待。

更麻烦的是,这两个任务在业务上毫无关系。它们唯一的联系是「编号挨得比较近」。 这种耦合不会出现在任何设计图里,只会在线上偶发。

最后改成什么?一个看起来最笨的方案:每个任务一行计数器, UPDATE ... SET next_seq = next_seq + 1,改完读出来。 锁的范围从「一片」收到「一行」,而且所有写路径的加锁顺序被固定成:先锁任务行,再写事件表。 顺序固定,就不可能互相等。

教训不是「别用 MAX」。教训是:凡是靠扫描算出来的号,都有一个窗口期。 窗口期在测试里永远不出现,因为它需要两个真实的人(或两个 AI)在同一秒动手。

三、我给「安全」加的那道墙,把登录全堵死了

这件事是我做得最蠢的一次,也是印象最深的一次。

背景是:几个子系统各自还留着直连身份的入口,用户能绕过门户直接进去。 我想把这个口子堵上,让所有人都必须先到门户。方案听起来天经地义—— 把子系统的 /oauth/authorize 拦掉,302 回门户。

我改了,改完还测了:访问 /oauth/authorize 确实跳门户了,很好。收工。

二十多分钟后,问题来了。不是「有人抱怨」,是三个系统的登录同时不可用。 ERP 登录后跳到一个白页,另一个系统报 state 校验失败,还有一个干脆循环跳转。

根因很清楚:我拦的不只是发起端,还顺手拦了 /callback。 而 /callback 不是「登录入口」,它是身份服务回调回来的端点, 带着 code 和 state 两个查询参数。我一个 302, 把参数全吞了,回调落地页拿不到 code,只能判失败。

更糟的是这几个系统共用一套会话。一个系统的 state 校验失败, 把共享的那份会话也污染了,于是本来没事的那个也开始报错。一级事故变成三级。

处理方式是半小时内用备份回滚两个配置文件,reload,逐个验证。 回滚本身很顺利,但那半小时里我没法回答一个基本问题: 我改的这个文件,除了我以为的那个用途,还有谁在用?

事后定的规矩写得很直白:防绕过收口绝不能碰 /callback。 它是合法回跳端点,携带凭据,任何重定向都是在破坏协议。 要收口只能收「发起」,而且收之前必须先列出这条路径的所有调用方。

这条规矩后来救过一次场。有人提了个很像的方案,想一并把回调也「规范化」掉, 我把这次的日志翻出来贴给他,方案当场改掉。

四、一句「build 通过」

这件事故的性质和前两个不一样。前两个是技术坑,这个是信任问题。

前端有个页面,几次交付回执上都写着「本地 build 通过,可以上线」。 线上表现却一直不对:单据保存按钮点下去没反应,控制台报栈溢出。 我以为是部署没生效,反复确认了几次静态文件的哈希,确实是新的。

后来把源码拉下来看,一眼就看到了:

import { saveInstallment } from '@/api/hsr'   // 第 175 行

async function saveInstallment() {          // 第 267 行,同名
  // ...
  await saveInstallment(...)                // 第 271 行,调自己
}

同名声明,函数体里调用的还是自己。这个函数一执行就是无限递归,直到栈溢出。 编译器不报错,因为语法完全合法。

但真正让我后背发凉的不是这个 bug,是另一个发现: 这台服务器上根本没有 node。

也就是说,那几次回执里的「build 通过」不可能是在服务器上跑的; 而线上正在提供的静态文件,比那些提交还老。 前端改动从头到尾就没真正部署过——所有人以为在测新版本,其实一直在测半年前的构建。

这件事之后,我这边加了两条硬约束:

我还专门检查了那台机器的 PATH 和包管理记录,确认没有 node 二进制, 只有几个项目残留的 node_modules 目录——那些目录长得太像「装过了」, 大概就是历次误判的来源。

五、静默的 TypeError

这个坑最阴。它的症状是「偶尔登不进去」,不是必现,也没有规律。

有一段时间,从一个页面点进业务系统,会莫名其妙落回默认页。不报错,不白屏, 就是「点了,跳到不该去的地方」。用户描述得很朴素:进去又出来了。

我第一反应是登录态。查了 cookie、查了域、查了会话互踢,都正常。 第二反应是路由配置。翻了半天,也没问题。第三反应才是去看那个签发跳转凭据的接口。

接口返回 200,但内容是空的。

再往里看,问题在这一行:

async with httpx.AsyncClient(timeout=5.0, connect=3.0) as c:

这行代码在旧版本 httpx 下能跑。环境里的 httpx 升到 0.27 之后, connect 不再能作为独立的关键字参数传进构造函数,必须用 httpx.Timeout(5.0, connect=3.0) 构造一个 Timeout 对象。 直接传 connect=,在 0.27.2 上抛 TypeError。

而它被抛在了 try 里,被一个宽泛的 except 接住了,接住之后返回了空结果。 于是整个链路对外表现是:200,内容为空。 前端拿到空凭据,兜底逻辑把它当成「没有票」,顺手跳回默认页。

从现象到根因隔了三层:跳错页 ← 空票 ← 被吞掉的 TypeError。 如果不是有人坚持「接口 200 但 body 是空,这本身就不对」,它还能再藏很久。

这是我这几年最警惕的一类故障:异常被吞,症状离根因三层以上。 后来我要求所有兜底 except 必须至少记一行日志,带栈; 以及「接口 200 但关键字段为空」要按故障处理,不能当正常分支。

六、一个斜杠

这个最短,但也最能说明「看起来跑起来了」有多不可靠。

有个后台入口挂在子路径下,比如 /dashboard/,nginx 要做单页应用兜底: 找不到文件就返回入口页,让前端路由接管。我写的是常见的 try_files $uri /index.html;。

结果:访问 /dashboard/,浏览器地址栏没变,但页面上出现的是另一个站点的首页。 不是 404,不是错误页,是完整、正常、指错地方的页面。

原因是那个 /index.html 被 nginx 当作内部重定向处理, 重定向到根路径,命中了另一个 location /,于是把主站首页吐了出来。 兜底要写全路径:try_files $uri /dashboard/index.html;。

还有一层:location = /dashboard(没斜杠)必须先 302 到 /dashboard/,否则匹配规则会走到别的地方去。 一行配置里藏着两个陷阱,都很安静。

从那以后,我验证页面不再只看「HTTP 200」,一定加一条: 抓页面里的特征字符串,确认它是它自己。200 只证明有东西被返回, 不证明返回的是你要的东西。

七、我把服务器改错了

这次没有任何技术难度,纯粹是我把两个域名对应的服务器搞反了。

起因是拿到一个新签的证书,要往服务器上配。我脑子里有个印象: 「这个域名指向新机器」。于是证书装到了新机器,配置也写在新机器上, 还一本正经地做了详细验证——绕过代理、严格校验 TLS、逐条路径测状态码,全绿。 我把结果报了上去。

然后被告知:方向反了。这个域名用的是老服务器,另一个域名才是新服务器。

回看当时的推理,错在第一步:我从「最近把哪个域名切到了新机器」倒推另一个域名的归属, 而这两件事之间根本没有逻辑关系。我用的不是事实,是印象。 而印象一旦形成,后面所有的验证都会围着它转——我验的是「配置对不对」, 从没验「这台机器该不该配这个域名」。

修正不算难:撤掉错的那份配置,在正确的机器上照搬一份完整服务块,只换域名和证书路径。 但我加了一条流程约束:动域名、证书、DNS 之前,先写一行「域名 → 服务器 IP → 谁在用」, 写完再动手。这一行字花十秒,能省掉一次方向性错误。

还有几个没上台面的

有些事故太小,不值得单独一节,但都是真实发生过的,随手记下:

写在最后

把这七件事放在一起看,会发现一个共同的形状: 事故几乎都不是「做错了什么」,而是「没有验证什么」。

没验证锁的范围、没验证回调端点还有谁在用、没验证线上到底是哪个构建、 没验证异常被谁接住了、没验证页面上那行字是不是自己的、没验证这台机器该不该配这个域名。

而 AI 参与进来之后,这件事变得更需要刻意: 它会非常自信地告诉你「已完成」,因为它的上下文里确实一切都成立。 它不知道线上是半年前的构建,也不知道隔壁系统正在用同一个回调端点。 它只知道自己那一侧的逻辑通了。

所以我现在的做法很笨:凡事要证据,不要结论。 不看「已完成」,看命令输出、看响应体里的特征字符串、看哈希、看日志里那一行栈。 这不是不信任 AI,这是不信任「任何没有验证过的说法」——包括我自己的。

写于 2026-11-04 · 逐条对照当时的操作记录整理,细节未做美化 下一篇:规矩不是想出来的,是吵出来的 →