为什么这类 bug 最贵
有一类 bug 特别贵:它的症状和它的根因之间没有因果关系。
普通的 bug 好办 ------ 空指针报在空指针那一行,类型错误报在类型转换那一步。你顺着栈走,总能走到。
但有一类 bug,报错信息会把你送进一个完全无关的子系统。你在那里查了两小时,每一分钟都觉得自己接近了,直到发现方向从一开始就是错的。
我最近连着撞上六个这样的坑。更麻烦的是,其中两个是我自己写的工具骗了我。把它们记下来,是因为每一个都配一条可复用的判定规则 ------ 而规则比解法值钱,因为解法只治一个 bug,规则能防一类。
一、报错完全不提"安装被移动过"
症状
ini
Fatal Python error: init_fs_encoding: failed to get the Python codec
of the filesystem encoding
LookupError: no codec search functions registered: can't find encoding
sys.prefix = ''
sys.base_prefix = ''
sys.path = [ 'D:\\Python310\\python310.zip',
'C:\\Users\\...\\Programs\\Python\\Python310\\Lib\\', ← 另一棵树
'D:\\Python310' ]
报错里出现了 codec、encoding、filesystem。任何一个正常人看到这三个词,第一反应都是"编码问题"。
误判方向
我一开始去查的是:系统区域设置、PYTHONUTF8、PYTHONIOENCODING、控制台代码页。
全都无关。
真因
这套 Python 安装被移动过。 sys.executable 指向新路径,但标准库查找路径还留在旧的用户目录下,于是解释器找不到自己的 encodings 包。没有编解码器,init_fs_encoding 就失败。
关键陷阱:一个"看起来正常"的假信号
ruby
$ python -V
Python 3.10.11 ← 正常输出
打印版本号不需要 codec,所以它能跑。 而任何真正的脚本都需要。这个假信号让排查多花了不少时间 ------ 因为"python 能跑"这一条一直在暗示我"解释器本身是好的"。
规则
sys.prefix == ''就是体检报告。 健康的解释器从不报空 prefix。
python -V什么也证明不了。 要测就用python -c "print(1)"。别去调试安装。 换一个解释器,继续干活。修 Python 解释器本身没有生产价值。
二、两个独立工具同时说"文件不存在",而文件就在那儿
症状
目录列表里有这个文件。os.path.exists() 对同一个路径返回 True。
然后:
python
>>> open(path)
FileNotFoundError: [Errno 2] No such file or directory
$ node -e "fs.readFileSync(path)"
Error: ENOENT: no such file or directory
两个完全独立的运行时报了同一个错。
误判方向
两个独立工具同时失败,读起来像环境问题,不像路径问题。于是我去查:
- 是不是沙箱拦了文件访问?
- 是不是路径里的中文有编码问题?
- 是不是权限不对?
都不是。
真因
磁盘上的文件名是 n8n_ai_pipeline.json。 我请求的路径是 8n_ai_pipeline.json。
少了一个字母。 旁边有个同名目录 n8n_ai_pipeline,文件名是手写的,写的时候把开头的 n 漏掉了。
为什么这么难看出来
两个放大混淆的因素:
第一,"两个工具都失败"这个信号本身在骗人。 一个工具失败可能是工具的问题,两个工具失败看起来必然是环境的问题 ------ 但这条推理默认了"我请求的路径是对的"。当输入本身错了,工具数量再多也证明不了什么。
第二,控制台把证据弄花了。 路径里有中文,GBK 代码页下 node 和 Python 打印出来的路径都是乱码:
arduino
'E:\\Ǯ֮·\\20_��Ʒ��\\8n_ai_pipeline.json'
看起来像"编码坏了",而不像"名字写错了"。乱码掩盖了一个字符级的差异。
规则
要回答"这个文件能不能打开",就调用
open()。 不要用os.path.exists()、不要用目录列表、不要用 glob ------ 它们回答的是不同的问题,而且它们可以和open()不一致。路径里有非 ASCII 字符时,用
ascii()打印,或者比较码点。 永远不要靠"控制台渲染成什么样"来判断路径对不对。
python
print(ascii(path)) # 'E:\\\u8d5a\u94b1\u4e4b\u8def\\...'
print("-".join(hex(ord(c)) for c in path)) # 逐字符码点,乱码无从藏身
三、我的扫描器把平台文档当成了数据
症状
我写了一个赏金扫描器,去 GitHub 上找带金额的悬赏 issue。第一次运行,它报出五条 $100 的赏金 ------ 其中四条在同一个仓库、同一天创建、金额相同。
看起来太美了,所以我去核实。
真因
Opire 会给它参与的仓库里每一个 issue 注入一个 <details> 页脚,内容是它自己的语法说明:
html
<details><summary>This repo is using Opire - what does it mean? 👇</summary>
💵 Everyone can add a reward with `/bounty $100` on any issue.</details>
于是:标记存在 ✅、金额存在 ✅、两者都来自平台在解释自己,而不是来自任何人出钱。
真实悬赏数:0。我的报告说 5。
修复
解析之前先剥掉平台注入的样板:
python
def strip_boilerplate(body: str) -> str:
body = re.sub(r"<details[\s\S]*?</details>", " ", body, flags=re.I)
body = re.sub(r"<!--[\s\S]*?-->", " ", body)
body = re.sub(r"^.*(this repo is using opire|everyone can add).*$",
" ", body, flags=re.I | re.M)
return body
并把这个页脚本身做成回归测试的 fixture ------ 用让你出错的那份数据当测试用例。
规则
当输入来自别人的系统时,输入的一部分是"那个系统对自己的说明"。 你搜的任何模式,都可能匹配到文档而不是内容。
对每个你抽取的字段问一句:"这段文字出现在这里,有没有可能不是为了我以为的那个原因?"
顺带一提,这个 bug 还有一层教训:第一次运行就报出"四条同仓库同日同额"时,我该当场起疑。 数据太整齐,本身就是异常信号。
四、控制台乱码,不等于文件坏了
症状
中文文件名和非 ASCII 输出在终端里显示成 \u8d5a 样式的乱码,或者成片的替换字符。
真因
Windows PowerShell 5.1 用**控制台代码页(这里是 GBK)**去解码子进程的输出,而 Python 和 Node 写的是 UTF-8。磁盘上的字节完全正确。
规则
把控制台渲染当"显示"问题,永远不要把它当"数据"的证据。
要真正检查一个工具产出了什么:
python
with open(path, "r", encoding="utf-8") as fh:
print(ascii(fh.read(120))) # 每个字符都是码点,不经过控制台渲染
同一族还有几个坑,都很便宜但很致命:
open(path, "w")不写encoding=时用系统默认编码(这里是cp936),会静默写坏 UTF-8 内容 。永远显式传encoding="utf-8"。- CSV 按纯 UTF-8 写,Excel 打开是乱码。写
utf-8-sig。 - PowerShell 5.1 读
.ps1文件时,如果不是 UTF-8 带 BOM ,会按 GBK 解析,直接语法报错。而很多编辑器保存时会把 BOM 去掉 ------ 每次编辑后要确认补回。
五、我验证了源码,没验证产物
症状
打包了一个 zip,然后跑了完整的验证套件 ------ 对着源码目录跑的。全绿。
然后我把这个 zip 解压到临时目录,在解压出来的包里 跑了一遍内置的校验脚本 ------ 立刻失败。
真因
打包时把工作流文件放在了 workflows/ 子目录里,而校验脚本按同级目录去找。源码目录下两者是平级的,所以源码侧永远测不出来。
规则
一个构建不是靠测它的输入来验证的。把产物解压出来,测产物。
顺带做了两件便宜事:把校验脚本一起打进包里 (买家/使用者可以自己验证"我拿到的不是一堆坏文件"),以及让脚本同时容忍两种合理布局,而不是假定其中一种。
六、"注册成功"不等于"你能收到钱"
这条不是代码 bug,但它是最贵的一类"症状不指向根因"。
症状
我在判断某个平台能不能给某个地区的卖家打款。注册流程一路顺畅,没有任何阻碍。
真因
答案在一个支持提现国家列表 的文档页上:目标地区不在列表里。而该页明确写着,不在列表里就意味着这个平台对该地区完全不可用。
注册流程的顺畅,和能否收到钱,是两个完全不相关的事情。
同一次排查还推翻了我之前两个想当然的假设:
- 不同的提现通道有不同的国家列表。 "支持 200+ 个国家通过 PayPal 提现"和"这 119 个国家支持银行转账",两者不矛盾,而你能不能拿到钱取决于适用哪条通道。
- 平台可以支持某个支付服务商,同时明确排除它。 有一份文档直接写着不接受 Payoneer、Wise、支票和电汇作为提现方式 ------ 这直接推翻了我"所有自由职业平台都能走 Payoneer"的假设。
规则
凡是涉及钱的判断,找到权威列表,逐字读。不要从"注册表单接受了我"推断你有资格。
从这六个坑里提炼的判定纪律
1. 让每一个判定都有失败的可能
如果一个检查不可能失败,它就不是检查。 我那个校验脚本在源码目录下永远绿 ------ 那时候它证明的不是"包是好的",而是"我没在测包"。
2. 每个性质用两个独立信号
计数和行为,各验一次。计数器可能因为错误的原因而正确;行为检查可能在计数已经坏掉的时候依然通过。
这两个信号不一致的频率,比你以为的高得多。而不一致的时候,通常其中一个是 bug。
3. 静默的成功比响亮的失败更糟
一个坏掉之后不再报警 的监控、一个错误率 5% 却什么都不说的抽取器 ------ 两者都比没有自动化更糟,因为你已经在信它了。
把失败状态写进输出格式里,而不是让它表现为"没消息"。
4. "平时能跑,满载就崩" 意味着累积
能区分"压力"和"累积"的,是降速长跑,不是短时压测。负载降低后如果还崩,就是累积问题。
5. 不要相信一次前置条件没检查过的运行得出的结论
我曾经在驱动根本没加载的情况下跑完一整轮测试,然后对着结果分析。测试跑完了,结论一文不值。
6. 用让你出错的那份数据当测试用例
那个 Opire 页脚的 bug 修完之后,我把页脚本身做成了回归 fixture。能骗过你一次的数据,会一直躺在那里等着骗你第二次。
结语
这六个坑里,真正花时间的不是"修",是"发现自己走错了方向"。
而所有走错方向的案例有一个共同点:我信任了一个看起来很合理的中间信号。
- "python -V 能跑" → 解释器是好的
- "两个工具都失败" → 是环境问题
- "报错里提到 encoding" → 是编码问题
- "校验全绿" → 产物是对的
- "注册成功了" → 能收到钱
这些信号没有一个是错的,它们只是不回答我问的那个问题。
所以那条最值钱的规则其实只有一句:
在任何判断之前,先问一句:我现在依赖的这个信号,真的在回答我这个问题吗?
如果答案是"不完全是",那就去找那个直接回答问题的证据 ------ 哪怕它更麻烦、更底层、更不好看。
本文基于真实调试记录整理。文中所有命令、报错与输出均为实际复现,未做简化。