2282 words
11 minutes
优秀的软件设计看起来平淡无奇

这是一篇翻译的文章,原作者的链接 https://www.seangoedecke.com/great-software-design/ ,作者现在github工作,专业是哲学专业。这篇主要讲述关于面对故障业务失败隐患时如何设计。

多年前,我曾花大量时间评审编程挑战。挑战本身非常直接——构建一个调用 API的命令行(CLI)工具,并允许用户翻页和查看数据。我们允许使用任何编程语言,所以我见识了各种各样的实现方式 1。有一次,我遇到了一个我认为简直是完美的答案。它是一个单独的Python文件(总共大概三十行代码),风格非常朴实无华:用最简单、最直接的方式满足了挑战的要求。

我把它发给另一位评审员,建议我们将其作为满分(10/10)的参考标准。但当我得知他甚至不会让这位候选人进入面试时,我着实震惊了。在他看来,这份代码没有展现出对高级语言特性的足够理解。它太简单了。

多年后的今天,我更加确信我是对的,而那位评审员是错的。优秀的软件设计就应该是“过于简单”的。我想现在我终于可以开始阐明原因了。

消除风险#

每个软件系统都有很多可能出错的地方。这些有时被称为系统的“故障模式”。下面是一些例子:

  • SSL 证书过期且未续订
  • 数据库写满,导致速度过慢或内存溢出
  • 用户数据被覆盖或损坏
  • 用户看到损坏的用户界面(UI)
  • 核心用户流程(例如保存记录)无法工作

围绕潜在的故障模式进行设计有两种方法。第一种是反应式(reactive)的:在高风险代码块周围添加补救方式(rescue clauses),确保失败的 API 请求会重试,设置优雅降级(graceful degradation)以防错误破坏整个用户体验,添加日志和指标以便轻松识别错误等等。这些都值得去做。事实上,我认为这种(坦率地说,近乎偏执的)态度正是一位经验丰富的软件工程师的标志。但这样做并不代表好的设计,反而常常是在为一个糟糕的设计修修补补。

处理潜在故障模式的第二种方法,是在设计上就将其彻底根除。这在实践中意味着什么呢?

保护热路径#

有时,这意味着将组件移出“热路径”(hot paths ,指系统中频繁执行的关键路径)。我曾经手过一个产品目录的接口(endpoint),由于其他设计上的原因,它的效率极低,处理每条记录大约需要 200 毫秒。这使我们面临几种棘手的故障模式:应用其余部分的资源被耗尽、索引请求时代理超时,以及用户在等待十秒钟后直接放弃。我们最终将构建接口的代码移到了一个定时任务(cron job)中,将结果存放在对象存储(blob storage)里,然后让产品目录接口直接提供这个存储对象。虽然那段每条记录耗时 200 毫秒的糟糕代码依然存在,但它现在处于我们的控制之下:它不会被用户行为触发,即使失败,最坏的情况也只是提供一个过时的数据而已。

移除组件#

有时,这意味着完全减少组件的使用。我曾参与过的另一个服务是一个文档客户关系管理系统(CRM),它有一套非常定制化的系统,用于从不同代码仓库中提取各种文档片段,并将它们拼接成数据库条目(有时甚至直接从代码注释中提取文档)。这在最初是一个好决策——当时很难让各个团队编写任何形式的文档,所以系统必须做到最大限度的灵活。但随着公司的发展,这套系统明显变得过时了。同步任务将一部分状态存储在数据库中,另一部分则存储在磁盘上,当磁盘上的状态与代码仓库不同步,或底层主机内存不足时,常常会触发奇怪的 git 错误。我们最终彻底移除了数据库,将所有文档迁移到一个中央代码仓库,并把文档页面重构成一个普通的静态网站 2。这样一来,各种潜在的运行时和运维上的错误就都烟消云散了。

集中化状态#

有时,这意味着对你的状态进行规范化。最糟糕的故障模式之一,是那些让你的状态(例如数据库的行)陷入不一致或损坏的错误:一个表显示一种情况,另一个表却显示不同的情况。这非常糟糕,因为修复这个错误本身仅仅是工作的开始。你还必须回去修复所有受损的记录,这可能需要像侦探一样去查明正确的值应该是什么(在最坏的情况下,只能靠猜)。为了让你状态的关键部分拥有一个“单一事实来源”(single source of truth),即使要承受其他方面的很多痛苦,通常也是值得的。

使用稳健的系统#

有时,这意味着依赖那些久经考验的系统。对此,我最喜欢的例子是 Ruby 的网络服务器 Unicorn。它可能是你能想到的、在 Linux 上构建网络服务器的最直接、最简单的方式。首先,你有一个进程,它监听一个套接字(socket)并一次处理一个请求。一次只处理一个请求是无法扩展的:传入的请求在套接字上排队的速度会比服务器处理的速度快。那该怎么办呢?你只需要把这个服务器进程 fork(派生)很多份。由于 fork 的工作方式,每个子进程都已经监听着原始的套接字,因此标准的 Linux 套接字逻辑会负责将请求均匀地分配给这些服务器进程。如果任何一个子进程出了问题,你可以杀掉它,然后立刻再 fork 一个新的出来。

有些人认为如此钟爱 Unicorn 有点傻,因为它显然不如一个多线程服务器具有扩展性。但我喜欢它有两个原因。首先,因为它把大量工作交给了 Linux 的进程和套接字这些原生机制。这样做很明智,因为它们极其可靠。其次,一个 Unicorn 工作进程(worker)很难对另一个 Unicorn 工作进程造成任何不良影响。进程隔离远比线程隔离要可靠。这就是为什么 Unicorn 是大多数大型 Rails 公司选择的网络服务器,比如 Shopify、GitHub、Zendesk 等等。优秀的软件设计并不意味着你的软件性能超群,而是意味着它能很好地胜任任务。

总结#

优秀的软件设计看起来很简单,因为它在设计阶段就尽可能地消除了故障模式。消除故障模式的最好方法,就是不要去做一些花哨的事情(如果可以的话,甚至什么都不做)。

并非所有的故障模式都是平等的。你应该尽最大努力去消除那些真正可怕的模式(比如数据不一致),即使这意味着要在其他地方做出一些略显笨拙的选择。

这些都是相对枯燥、不那么吸引人的想法。但优秀的软件设计就是枯燥且不吸引人的。人们很容易对像 CQRS、微服务或服务网格这样宏大的理念感到兴奋。但优秀的软件设计看起来并不像那些宏大而激动人心的理念。大多数时候,它看起来什么都不像。

Footnotes#

  1. 补充说明,事后来看,我认为这是一个不公平的决定。显然,评审员们对自己公司使用的语言最为熟悉,所以用其他语言(例如 Java)提交的候选人处于不利地位。我们当时确实试图减轻这种情况,但更好的做法应该是告诉候选人,只需在几种常用语言中任选其一即可。

  2. 关于那个项目的另一个有趣战绩:我们当时使用数据库支持的会话(session)来存储用户登录信息,但没有任何清理机制。当我们试图将这个老应用迁移到一个新的平台基础设施层时才发现了这一点——数据库备份里包含了整整一千万行会话记录。

优秀的软件设计看起来平淡无奇
https://techat.online/posts/great-software-design-looks-underwhelming/
Author
Colin
Published at
2025-08-24
License
CC BY-NC-SA 4.0