2012 年 8 月 1 日上午 9 点 30 分,纽交所开盘。Knight Capital 的交易系统在接下来的 45 分钟里发出了大约 400 万笔订单,买进卖出再来回买回,涉及约 35 亿美元的股票。到 9 点 45 分,公司账面上少了 4.4 亿美元。当天收盘,Knight 的股价跌了 33%,第二年它被 GETCO 收购,这个名字就没了。
SEC 后来的报告把原因写得很清楚:Knight 部署新代码的时候,8 台服务器只更新了 7 台。剩下那台跑的还是旧版本,里面有个叫 Power Peg 的功能,2003 年就停用了,但代码一直躺在那里。新代码在别的地方把它的开关关掉了,那台旧服务器不知道,开盘后一收到订单,就照着老逻辑一路滚下去。
这个故事被讲烂了,通常的结论是「部署流程要规范」「要有回滚方案」。我觉得这个结论太浅了。真正值得问的是:为什么一段 2003 年就废弃的代码,九年之后还能被触发?因为当年删它的人做过一个判断——这东西没人用了,留着也不碍事。这个判断在 2003 年是合理的,在 2012 年 8 月 1 日上午 9 点 30 分,它值 4.4 亿美元。
大部分人讲的「编程思维」,其实是在讲「解题思维」
你去搜编程思维,出来的是那套熟悉的东西:分解问题、找出模式、抽象、写算法。这套说法没错,但它更像数学课上的东西。数学题是可以做完了交卷的,程序不行,程序交卷的那一刻才开始。
我印象比较深的是 Peter Naur 1985 年那篇《Programming as Theory Building》。他的观点在当时挺刺耳的:程序真正的存在形式,不是硬盘上那份源码,而是写它的人脑子里那套「为什么这么写」的理论。人一走,理论就断了,代码还在,但已经没人真正懂它了。
按这个说法,Knight 那台服务器上跑的其实不是代码,是一具尸体。它九年前是什么样,现在还什么样,只是没有人再持有关于它的理论了。所以问题不在于「有没有及时删」,而在于——当一个人判断「这东西以后没用了」的时候,他其实是在赌一个不可逆的未来。
我觉得真正的分水岭,是对「可逆性」的敏感度
带了几年人之后我发现,新手和老手的差距,很多时候不在算法能力上,而在一个很土的问题:这个决定,错了以后还能不能改回来?
新手习惯先写主流程,把核心逻辑、数据库写入、第三方调用、发消息全揉在一个函数里,一口气写完,跑通再说。老手相反,他会先把所有不能撤销的动作圈出来,能推后的推后,能挪到边界上的挪到边界上。
前两年做过一个电商项目,要接短信服务商。当时我坚持把所有的短信发送请求全塞进一个 180 行的模块里,其他地方只允许调一个 notify(userId, templateId, params)。同事觉得多此一举,直接调 SDK 不香吗。
后来三个月里换了两家服务商,第一家的到达率太差,第二家涨价。我们两次替换加起来花了不到两天,改动只在那 180 行里。如果当初散在二十几个业务文件里,这事儿的成本不是两天,是两周,而且中间一定会有漏改的地方。
换服务商是可逆的,前提是你把它的边界画出来了。数据库选的字段类型,某种程度上也是可逆的,加个字段改个索引而已。但删除数据不可逆,给用户发了短信不可逆,扣了款不可逆,对外发布了接口版本号也不可逆。
我现在的做法很土,就是每写一个稍微重要点的功能,先在本子上画一栏,写三句话:这一步错了,五分钟内能不能恢复?恢复需要谁配合?如果不恢复继续往下走,会污染多少下游数据?答不上来的,就先别写代码。
一些具体能落地的做法
第一,把所有不可逆动作集中到尽可能少的几个地方。理想状态是一个应用里只有一层「出口」,写库、发消息、调外部接口,全从那儿走。这一层之外的代码全部是纯计算,给同样的输入就出同样的结果,随便改,随便测。
第二,删代码比加代码重要,但删代码要付利息。判断一段废弃代码能不能删,不要问「现在还有人用吗」,要问「如果它某天被意外触发,最坏结果是什么」。如果是打印一条日志,留着无所谓。如果是下单、扣款、发通知,那就得删,而且要在所有环境里删干净——Knight 那 7 台新服务器删干净了,剩一台没删,等于没删。
第三,警惕「临时方案」。我的经验是,一个临时方案如果在代码里活了超过两个迭代还没被替换,它就已经是正式方案了,只是没人愿意承认。这时候要么立刻替换,要么把它补上测试、日志、监控,当正式的对待。别让它以「临时的」身份继续跑,因为没人会去 review 一个临时方案。
第四,给每个异步动作加可观测的痕迹。短信发没发出去,订单有没有重复提交,这些事靠看代码是看不出来的,得靠日志和监控。发消息前先落一条记录,发送后回写状态,重复请求用幂等键挡掉。这套东西写起来很烦,但它是把「不可逆」变成「可追溯」的唯一办法。
最后说点个人的
写代码写到第八年,我越来越觉得,编程思维不是让人变聪明的东西,它更像是逼着你承认自己一定会错、而且不知道会错在哪里。
数学题做错了,扣分。程序写错了,可能是别人的钱、别人的时间、别人的一个下午。这两者的重量不一样,所以思考方式也不该一样。
所以我不太喜欢「编程思维就是分解问题」这个说法。分解只是手段,真正的问题是:分解完之后,你打算怎么给自己留退路。「能不能一次做对」和「做错了能不能便宜地改回来」,后者才是普通人真正该练的。
Knight 那 4.4 亿美元,买的不是部署流程的教训,是一个人九年前那句「留着吧,不碍事」的代价。