一个 += 号,三场事故
列表加等于字符串,在 Python 里是把字符串逐字符摊开。这个特性引发的 bug 连环爆发了三次,前两次都没抓到根因。
这是个值得写进教科书的 bug,因为它在我们这里连环爆发了三次,前两次都被修好了——修的是症状。
背景:站点的分类页由一个生成器脚本产出。某次改版时,代码里写了一个看似无害的操作:把一段 HTML 字符串用 += 加进列表。写的人没意识到,列表 += 字符串,等价于把字符串逐字符摊开塞进列表。“div” 不会作为一个元素进去,会变成三个元素:“d”、“i”、“v”。之后列表 join 成文件,产出的页面里, HTML 标签被拆成一行一个字符,一个七百多行的“残渣文件”。
第一次爆发,表象是某个分类页下半屏直接吐出源码。当晚手工修复:还原损坏内容、按标准骨架重建页面。看着正常了。
第二次爆发,另有页面排版错位。又手工修:去重、重组、闭合标签。又看着正常了。
第三次,同一个病再次发作,这才被逼着往深处挖,真相是同源的两个 bug:除了逐字符摊开,还有一处输出顺序错误,让侧栏嵌进了主栏内部,导致布局规则失效、侧栏坠到页尾。前两次手工修复之所以“正常”,纯粹因为手工重建时恰好用了正确顺序——症状消失,病灶安然无恙,而且手工版本不在生成器的数据里,下次重新生成必然复发。
根治方案分两层。代码层:先闭合主栏再挂侧栏,顺序修正;防护层:生成器落盘前加了四道断言——侧栏必须存在、标签嵌套深度必须为一、全文括号配平、以及专门针对这次事故的“残渣签名”检测(文件里不许出现单字符行)。此后重新生成全部分类页,零复发。
复盘时我反复想一个问题:为什么前两次没抓到根因?答案是手工修复太好用了。手工修又快又直观,修完页面明明白白是对的——它把“病人”救活了,也把“病灶”藏好了。如果第一次修复时就追问一句“损坏的东西为什么会变成这样,什么代码能产出这种形状”,这场连环事故两场就该终局。
所以现在我们的规矩多了一条:修完 bug,必须回答“什么样的代码能产出这种错误”。答不出来,修的就不是根因,是运气。