七个把系统搞挂的夜晚,
和它们各自的根因
这些事故没有一件是「模型不够聪明」造成的。全部是有人在动手之前,少问了那么一句: 这条路径还有谁在用?这个报错被谁吞了?我凭什么相信它说做完了?
写这篇不是为了记功,是因为我自己忘性大。每次事故过去两周,细节就开始模糊, 只剩一句「那次好像是因为死锁」。而模糊的教训等于没教训——下次还会踩, 甚至踩得更有信心,因为「我记得这个坑我处理过」。
所以我把七件事按当时的样子写出来:先看到什么症状、往哪个方向猜错了、最后根因到底在哪、 事后加了什么约束。有几件事根因在代码里,有几件事根因在人身上。 后者更麻烦,也更值得写。
一、两个工单拿到同一个号
症状很不起眼:任务列表里偶尔出现两条编号相同的记录。不是天天有,大概几百次派单里撞一次。 第一次看到的时候我以为是显示层面的问题,刷新一下就好了。刷新没好,才意识到是数据库里真的重复了。
当时的取号逻辑朴素到可笑: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 通过」不可能是在服务器上跑的; 而线上正在提供的静态文件,比那些提交还老。 前端改动从头到尾就没真正部署过——所有人以为在测新版本,其实一直在测半年前的构建。
这件事之后,我这边加了两条硬约束:
- 构建在哪台机器上做的,必须说清楚。服务器没有 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 → 谁在用」, 写完再动手。这一行字花十秒,能省掉一次方向性错误。
还有几个没上台面的
有些事故太小,不值得单独一节,但都是真实发生过的,随手记下:
-
「拉不下来,像是网络问题」——实际是代码仓库目录里一部分文件属主变成了 root,
普通用户没权限写入。报错信息长得像网络失败(
unpack-objects failed), 排查方向被带偏很久。定下规矩:拉代码前先统一属主。 - 临时文件删不掉——环境变量里的临时目录是 Windows 风格路径, 在类 Unix 环境下拼出了带盘符的混合路径,被安全删除工具判定为非法路径直接拒绝。 症状是「命令返回 64,什么也没发生」。这类问题的本质是环境不干净,不是命令写错了。
- 半成品留在工作区——有一次协作方改到一半,改动没提交也没还原, 还和主线产生了冲突。我接手时只能先 stash 保全、再打补丁备份, 然后等对方决定。这几天的功能是空转的。教训:临时的东西也要有归宿, 不能指望「明天再收拾」。
- 「服务端全绿,但用户说打不开」——查到最后是用户本机还在缓存旧解析结果, 以及本机走了代理。我这边也踩过反向的坑:自己的 curl 显示的是本机回环地址, 因为代理把它接了。判断「是不是服务端的问题」之前,先把自己的链路捋直。
写在最后
把这七件事放在一起看,会发现一个共同的形状: 事故几乎都不是「做错了什么」,而是「没有验证什么」。
没验证锁的范围、没验证回调端点还有谁在用、没验证线上到底是哪个构建、 没验证异常被谁接住了、没验证页面上那行字是不是自己的、没验证这台机器该不该配这个域名。
而 AI 参与进来之后,这件事变得更需要刻意: 它会非常自信地告诉你「已完成」,因为它的上下文里确实一切都成立。 它不知道线上是半年前的构建,也不知道隔壁系统正在用同一个回调端点。 它只知道自己那一侧的逻辑通了。
所以我现在的做法很笨:凡事要证据,不要结论。 不看「已完成」,看命令输出、看响应体里的特征字符串、看哈希、看日志里那一行栈。 这不是不信任 AI,这是不信任「任何没有验证过的说法」——包括我自己的。