好的软件设计,看起来平平无奇
多年前,我花了很多时间评审编程挑战的作业。挑战本身非常直接——构建一个命令行工具,调用一个 API,让用户可以翻页并查看数据。我们允许使用任何编程语言,所以我看到了五花八门的实现方式。有一次,我遇到了一个我认为简直是完美的作品。它只有一个 Python 文件(总共大概三十行代码),风格非常朴实:用最简单、最直接的方式满足了挑战的要求。
我把这份作业发给另一位评审,建议把它作为“满分作业”的参考标准。但他的回复让我大吃一惊,他说他不会让这个候选人进入面试。在他看来,这份作业没有展现出对各种高级语言特性的足够理解。它太简单了。
多年后的今天,我更加确信我是对的,而那位评审是错的。好的软件设计,就应该是“过于简单”的。 我想,现在我终于能说清楚为什么了。
消除风险
每个软件系统都有很多可能出错的地方,这些有时被称为系统的“故障模式 (failure modes)”。举几个例子:
SSL 证书过期了,没能续期
数据库满了,变得太慢或内存不足
用户数据被覆盖或损坏
用户看到了一个崩溃的界面
核心用户流程(比如保存记录)无法工作
围绕潜在的故障模式,有两种设计思路。第一种是被动响应:在有风险的代码块周围加上补救措施,确保失败的 API 请求会重试,设置优雅降级 (graceful degradation) 以免一个小错误毁掉整个用户体验,添加日志和指标以便快速定位 bug,等等。这些都值得做。事实上,我认为这种(坦率地说,近乎偏执的)态度,正是一个经验丰富的软件工程师的标志。但这样做,并不代表设计得好,反而常常是在为糟糕的设计打补丁。
处理潜在故障模式的第二种方法是,从设计上就让它们消失。具体要怎么做呢?
保护核心路径
有时候,这意味着将某些组件移出核心路径 (hot path)。我曾经手过一个产品目录接口,由于其他的设计决策,它的效率极低,处理一条记录大约需要 200 毫秒。这给我们带来了几个棘手的故障模式:消耗掉应用其他部分的资源、索引请求时代理超时、用户等了十秒还没响应就直接放弃了。我们最终把这个接口的构建代码放进一个定时任务 (cron job) 里,把结果存到 blob 存储中,然后让产品目录接口直接提供这个数据块。那个每条记录 200 毫秒的烂代码还在,但它现在处于我们的控制之下了:用户的操作无法触发它,就算它失败了,最坏的情况也只是我们提供了一个稍微有点过时的数据块。
移除组件
有时候,这意味着彻底减少组件的数量。我做过的另一个服务是一个文档管理系统,它有一套非常定制化的系统,能从不同的代码仓库里拉取各种文档片段,然后把它们拼接到数据库条目里(有时甚至直接从代码注释里提取文档)。这在当时是一个不错的决定——因为很难让各个团队去写任何形式的文档,所以系统必须做到最大程度的灵活。但随着公司的发展,这套系统就显得过时了。它的同步任务会把一些状态存在数据库里,另一些存在磁盘上,当磁盘上的状态与代码仓库不同步,或者底层主机内存不足时,经常会触发奇怪的 git 错误。我们最终彻底移除了数据库,把所有文档都迁移到一个中央仓库里,然后把文档页面重构成一个普通的静态网站。就这么一下,各种可能出现的运行时和运维 bug 都被消除了。
集中化状态
有时候,这意味着让你的状态规范化。最糟糕的一种故障模式,是那些让你的状态(比如数据库里的数据行)陷入不一致或损坏状态的 bug:一张表里是这么说的,另一张表里说的却不一样。这很糟糕,因为修复 bug 本身只是工作的开始,你还必须去修复所有被损坏的记录,这可能需要像侦探一样去查明正确的值到底应该是什么(最坏的情况下,只能靠猜)。因此,在设计时,确保你的核心状态有一个单一事实来源 (single source of truth),通常是值得为此承受很多其他痛苦的。
使用稳健的系统
有时候,这意味着依赖那些经受过实战考验的系统。我最喜欢的例子是 Ruby 的 Web 服务器 Unicorn。它可能是你能想到的、在 Linux 上构建 Web 服务器的最直接、最简单的方式。首先,你有一个进程,监听一个套接字 (socket),一次处理一个请求。一次只处理一个请求肯定没法扩展:新来的请求在套接字上排队的速度会比服务器处理的速度快得多。那怎么办呢?你把这个服务器进程 fork 一堆出来。由于 fork 的工作方式,每个子进程都已经监听了原始的套接字,所以标准的 Linux 套接字逻辑会自动把请求均匀地分配给你的各个服务器进程。如果任何一个子进程出了问题,你可以干掉它,然后立刻再 fork 一个新的出来。
有些人觉得如此推崇 Unicorn 有点傻,因为它显然不如一个线程服务器 (threaded server) 可扩展。但我喜欢它有两个原因。第一,它把大量的工作交给了 Linux 的进程和套接字原语。这很聪明,因为这些原语极其可靠。第二,一个 Unicorn 工作进程很难对另一个工作进程造成什么破坏。进程隔离 (process isolation) 远比线程隔离 (thread isolation) 可靠。这就是为什么 Unicorn 是大多数大型 Rails 公司的首选 Web 服务器:Shopify、GitHub、Zendesk 等等。好的软件设计,并不意味着你的软件性能超群,而是意味着它非常适合当前的任务。
总结
好的软件设计之所以看起来简单,是因为它在设计阶段就尽可能地消除了故障模式。 消除故障模式最好的办法,就是不去做那些花里胡哨的事情(如果可以的话,甚至什么都不做)。
并非所有故障模式都是平等的。你要尽最大努力去消除那些真正可怕的(比如数据不一致),即便这意味着在其他地方做出一些看起来有点笨拙的选择。
这些都是相对枯燥、不那么酷的想法。但好的软件设计就是枯燥且不酷的。人们很容易对像 CQRS、微服务 (microservices) 或服务网格 (service meshes) 这样宏大的想法感到兴奋。但好的软件设计看起来并不像那些宏大而激动人心的想法。大多数时候,它看起来什么都不像。
[^1]: 顺便说一句,事后来看,我认为这个决定是不公平的。显然,评审们对自己公司使用的语言最熟悉,所以用其他语言(比如 Java)提交的候选人就处于劣势。我们当时确实试图减轻这种情况,但更好的做法应该是告诉人们在几种常用语言里任选其一。
[^2]: 关于那个项目的另一个有趣战地故事:我们用数据库支持的 session 来存储用户登录信息,但没有机制来清理它们。直到我们试图把这个老应用迁移到一个新的平台基础设施层时才发现这个问题,当时数据库备份文件里包含了一千万行 session 记录。