系统出故障了。当年负责写这个程序的开发者早在十五年前就去世了,现在已经没有人能读得懂他的代码了…
现在一些关键系统的运行仍依赖于过时的软件,但编写他们的人要么离职要么已经去世。中间也缺少维护或更新,导致现在几乎没人能理解它们,而且一旦出现 Bug 就会给企业造成不可挽回的损失。
而现实中的这种例子,远比你想象中的要多。
一个令人深思的故事
我的一位客户负责数项世界排名前一百的养老基金,该公司在前几个月成功的将程序搬到了云端。作为项目的主任架构师,前两天我很意外地直接收到了 CIO 的短信:“抱歉打扰,我们出 S1X 级的大问题了。你能下午飞过来吗?”
“S1X”是他们对“比最严重级别还要糟,级联影响到业务其它非直接相关部分”问题的定义。
事情看起来十万火急,当天晚上我就飞到现场进行了诊断,发现是该客户的系统中一个批处理任务发生了崩溃。
该任务每天晚上执行一次,通过写一个 CSV 文件为某些养老金计算缴费率,再将计算的结果输出到另一个收益(benefit)分配程序。原先收益分配程序设定为在缴费(contribution)低于预测(projection)时会向客户发出报警。由于上一个处理任务已发生崩溃,不再产生输出,因此程序认为“所有缴费为零”。
所以系统立即向该养老基金的经理们发出大量警报邮件,基金经理们被吓得赶紧从该项目里撤离了。
起初没有人找得出问题发生在哪里,在大家的记忆和工作记录中,这个批处理任务也从未崩溃过。编写该批处理程序的人已经在 15 年前离世了,并且数十年前就不再是该企业的员工了。尽管该批处理的程序代码规模不大,但非常难读。因为在编程时主要考虑的是提高计算效率,并未考虑如何适合他人阅读。当然,程序也没有留下任何测试。
事故发生的前一天,脚本在运行环境中的编排发生了一次更改。这被认为是导致事故的罪魁祸首。幸运的是,这个更改做了版本 push,工程部门据此回溯到先前的版本。但不幸的是,这只使事情变得更糟。
最后我们通过提供热修补脚本的方式解决了这个问题。但实质上这次崩溃已经给该基金造成了 170 万美元(约 1203 万人民币)的直接损失。
“数据溃烂”(Bit Rot)问题
事实上类似的故事并非孤例。我在 2012 年离开英特尔加入 Sun 公司,切身体会到他们的 SPARC 产品线做得是多么的糟糕。Sun 在互联网泡沫时代的杀鸡取卵行为,导致此后 SPARC 远远落后于英特尔的至强产品线。
我的经理都和我说,要在英特尔至强服务器上运行模拟程序,因为 SPARC 服务器“非常慢”。更严重的问题在于,英特尔不仅 CPU 性能更好,而且具有制造优势,这意味着 CPU 的制造成本也大大降低。
随之而来的问题很明显:既然远远落后于竞争对手,那么为什么客户还是会购买我们的 SPARC 芯片?一位高级架构师向我给出了令人震惊的答案。那是因为我们的客户的软件系统过于僵化,只能在 SPARC/Solaris 系统上运行。而迁移到 x86/Linux 对客户而言是一项艰巨的任务。许多客户甚至丢失了源代码,无法重新编译应用。
他们能做的最好选择,就是升级到最新一代的 SPARC 处理器,无论这样的处理器性能多慢,价格多么昂贵。
这就是问题所在。我们整个部门的业务模式,完全围绕着这个国家中那些溃烂的软件系统。
维持运转的代价
我加入 Amazon 后,发现自己面对正是这样一个为遗留系统而构建适用的原型。该系统是另一个团队基于大量技术债务开发的,并且团队早已解散。之后该项目的所有权就移交给我们的团队……事实操明,这并非揽下一件好事。所以开发人员陆陆续续跳槽到其他的团队。我加入时的十多名团队成员中,一年后一位都没有留下。
该系统从表面上看并非一无是处。它使用了现代编程语言和技术栈(Java 8)编写,由拿着六位数收入的开发人员所组成的团队进行着日常维护,并不断更新以修复错误和添加新功能。尽管如此,测试修改(turnover)依然是拖累整个系统的显著负担。所有权变更和团队更替导致整体代码设计、端到端功能、最佳实践和调试技术等大量实操知识的丢失。尽管我们一直努力保持项目的推进,但感觉就像进入了一片沼泽地,四处亡羊补牢,深陷战争迷雾(Fog of War)中。
设想一下,一个运行在第一版 Java 上的项目,开发活动几乎为零,没有开发人员负责。还有比这更糟的项目吗?
如何预防发生灾难性故障
软件开发人员致力于构建健壮、无错误的系统,无需过多人工维护就能正常运行多年。据此标准,上面所说的养老金脚本无疑是非常成功的项目。
然而现实很严峻,再好的项目有发生崩溃的一天。最终,所有内容都需要做更新。导致原因可能是:
系统运行所基于的硬件系统停产了。
系统的依赖关系不再可用。
依赖关系中出现了严重安全漏洞,而唯一可用的安全补丁仅适用于并不后向兼容的版本。
应用开发基于一些已不再成立的假设。
甚至是整个世界发生了改变,软件必需因势而变。
无论出于何种原因,变更都是不可避免的。唯一的问题是,当最终需要变更时,它的代价有多大。
对于一个活跃维护的系统,变更就不会那么痛苦。但是,对于一个已有几年甚至数十年没有维护的系统,那么很多因素都可能会导致灾难性的错误。例如:
构建系统的开发人员已经离职。
源码丢失。
开发人员不了解如何正确地编译源码,并构建可执行文件。
开发人员不了解如何部署系统。
开发人员不了解如何正确地配置运行可执行文件。
开发人员对代码的架构和实现一头雾水。
开发人员不了解使代码功能正常运作所依赖的常量和隐含假设。
开发人员不了解如何运行自动化测试。
开发人员不了解如何调试测试问题。
开发人员不了解如何调试生产故障。
开发人员不了解如何获取生产日志和指标度量。
一种解决方案是对上述问题做尽量详细的记录。但文档并非最优的解决方案,因为其中难免会有遗漏。再全面的文档,也比不上自己亲自动手操作。
理想的做法
一个好的开端,就是企业指定专门的开发人员全面负责上述所有问题。但这还不够。
如果仅仅反复“阅读文档”,那么就会产生厌倦。人们并不能从中获得实践经验,进而解决实际问题。
如果加上“绩效审核”,那么人们更倾向于新的出彩项目,很有可能会简单地抹去并掩盖旧的问题,甚至直接从中剔除问题。如果没有真正面对的可交付成果或挑战,许多人自然会选择一条最轻松的道路。
真正要想避免软件发生溃烂,唯一的方法是确保项目的持续推进,即使看起来毫无必要,或是存在风险。建立、维护和验证实操知识和能力的最佳方法,就是不断做出变更,并测试这些变更是否能成功地执行。一旦项目停止推进,那么相关实操知识就会过时和消解。
即使原地打转听上去很可笑,但这对疏于维护而言仍是一种进步。事实上,维护人员总是可以做一些事情实现向前推进,虽然步伐可能很小。
一种做法是使用所有依赖关系的最新版本去更新开发环境,例如:
从 JDK 8 迁移到 11。
更新 JVM,使用 G1 垃圾回收机制替代原先的 CMS。
将 GCC 编译器从版本 5 更新到 7。
将数据库从 Postgres 9.5 更新到 Postgres 11。
将 AWS SDK 从版本 1.10 更新到 1.11。
在生产环境中安装最新的 Linux 发行版。
在一些情况下,依赖关系会过时。这时就需要考虑整体迁移到新的架构。例如:
从 SPARC 迁移到 x86;
从 Solaris 迁移到 Linux。
同样,保持开发人员的战斗力,可对应用做如下更新:
修复顽疾和一些边缘用例。
加固自动测试套件。
清理技术债务。
做性能优化。
实现新特性。
增量重构代码库,改进可读性。
上述变化势必会带来暂时性风险,并产生看似“不必要的”支出。开发人员难免会犯一些错误,甚至引入新的错误。面对这些代价时,人们很容易退而求其次,认为“如果还没有破裂,就不要修复。”
对于一个业务价值不大的系统,这可能是合理的做法。但对于任何关键任务系统而言,忽略问题将仅会掩盖较小的瞬态风险,而没有解决永久的灾难性风险。一旦有一天需要紧急调试或更新系统,那么企业将无所适从。
对于任何关键任务系统,至关重要的就是维持实操知识和能力。做到这一点的唯一方法,就是不断地开展实操。企业的大脑和肌肉一样,不使用,就会失效。
作者介绍:
Rajiv Prabhakar,毕业于密歇根大学和斯坦福大学,之后曾在 Intel 和 Sun Microsystem 公司工作五年,参与了 Jaketown、Skylake 和 SPARC 设计团队。之后转向软件相关工作,先后任职于 Amazon、Google、Engineers Gate,并数次自己组建创业公司。
原文链接:
https://software.rajivprab.com/2020/04/25/preventing-software-rot/