乐于分享
好东西不私藏

设计软件系统时,采用一切可行的最简方案

设计软件系统时,采用一切可行的最简方案

好文翻译,原文链接: https://www.seangoedecke.com/the-simplest-thing-that-could-possibly-work/

令人惊讶的是,这条建议能发挥多大作用。我真心认为你 始终 都能遵循这一方法:修复漏洞、维护现有系统、搭建新系统时,都可以这么做。

很多工程师在设计时,总想构思出“理想”的系统:架构设计精良、近乎无限可扩展、分布方式优雅,诸如此类。但我认为这完全是软件设计的错误方向。相反,你应该花时间深入理解当前的系统,然后采用一切可行的最简方案。

简单可能显得平淡无奇

系统设计需要掌握多种工具的运用能力:应用服务器、代理服务器、数据库、缓存、消息队列等等。初级工程师熟悉这些工具后,自然会想要使用它们。用多种不同组件搭建系统很有趣!在白板上画出方框和箭头也让人很有成就感——仿佛你在做真正的工程设计。

然而,和许多技能一样,真正的精通往往在于学会 少做 ,而非多做。野心勃勃的新手与沉稳的大师之间的较量,是功夫片里屡见不鲜的桥段:新手动作眼花缭乱、翻转腾挪;大师却大多静立不动。但不知为何,新手的攻击始终无法命中,而大师的一击则 decisive(决定性的)。

在软件领域,这意味着优秀的软件设计往往显得平淡无奇。你看不出任何复杂的操作。你能察觉到优秀的软件设计,是因为你会产生这样的想法:“哦,我没意识到问题竟如此简单”或者“不错,你根本不用做什么复杂的事”。

Unicorn 是优秀的软件设计,因为它依托Unix原语¹,为Web服务器提供了所有最重要的保障(请求隔离、水平扩展、故障恢复)。行业标准的Rails REST API也是优秀的软件设计,因为它以最简洁的方式,为CRUD(增删改查)应用提供了恰好所需的功能。我不认为这些是令人惊艳的 软件 ,但它们是令人赞叹的 设计 杰作——因为 它们采用了一切可行的最简方案 。

你也应该这么做!假设你要为一个Golang(Go语言)应用添加某种速率限制(rate limiting)功能。最可行的最简方案是什么?你的第一想法可能是添加持久化存储(比如Redis),用漏桶算法(leaky-bucket algorithm)跟踪每个用户的请求数。这确实可行!但你真的需要一整套新的基础设施吗?如果把这些用户请求数存在内存(in-memory)里呢?当然,应用重启时会丢失部分速率限制数据,但这真的重要吗?实际上,你确定你的边缘代理(edge proxy)²不支持速率限制吗?你能不能只在配置文件里写几行代码,而不是去实现这个功能?

也许你的边缘代理确实不支持速率限制。也许你无法用内存存储,因为并行运行的服务器实例太多,这样能设置的最严格速率限制会过于宽松。也许如果丢失速率限制数据就是致命的——因为用户正疯狂请求你的服务——那么最可行的最简方案就是添加持久化存储,你就该这么做。但如果你能采用更简单的方法,你难道不想吗?

你完全可以用这种方式从头搭建整个应用:从绝对最简单的部分开始,只有在新需求迫使你扩展时才进行拓展。这听起来有点荒唐,但确实有效。可以把YAGNI(你不会用到它)作为终极设计原则:它优先级高于单一职责原则,高于为工作选择最佳工具,也高于“优秀设计”。

采用最简方案会有什么问题?

当然,始终采用一切可行的最简方案存在三个主要问题。第一个是,由于不预判未来需求,最终会得到一个僵化的系统,或形成泥球式代码。第二个是,“最简”的定义不明确,所以最坏情况下,我等于在说“做好设计,就始终采用优秀设计”。第三个是,你应该构建可 扩展 的系统,而非只适应当前的系统。我们逐一来看这些反对意见。

泥球式代码

对一些工程师来说,“采用一切可行的最简方案”听起来像是让我停止做工程工作。如果最简方案通常都是快速的权宜之计,那这难道会导致系统彻底混乱吗?我们都见过代码库中层层堆叠的权宜之计,它们显然算不上优秀的设计。

但权宜之计真的简单吗?我认为并非如此。权宜之计的问题恰恰在于它 并不简单 :它会引入新的、需要一直记住的东西,从而增加代码库的复杂度。权宜之计只是 更容易想到 。找出正确的解决方案很难,因为你需要理解整个代码库(或大部分)。实际上,正确的解决方案几乎总是比权宜之计简单得多。

采用一切可行的最简方案并不容易。当你面对一个问题,最先想到的几个解决方案往往不是最简单的。找出最简方案需要考虑多种不同的方法。换句话说,这需要真正的工程能力。

什么是“简单”?

工程师们对“简单代码”的定义分歧很大。如果“最简”已经意味着“优秀设计”,那“采用一切可行的最简方案”难道不是同义反复吗?换句话说,Unicorn真的比Puma³更简单吗?采用内存速率限制真的比用Redis更简单吗?这里有一个粗略、直观的简单性定义⁴:

  1. 1. 简单系统有更少的“可动部件”:你使用时需要考虑的东西更少
  2. 2. 简单系统的 内部关联 更少。它们由接口清晰、直接的组件组成

Unix进程(processes)比线程(threads)更简单(因此Unicorn比Puma更简单),因为进程的关联性更低——它们不共享内存。这对我来说很有道理!但我认为这并不能给你工具,去判断每种情况下什么更简单。

内存速率限制和Redis相比呢?一方面,内存更简单,因为你不用考虑搭建一个独立的持久化服务所涉及的所有东西。另一方面,Redis更简单,因为它提供的速率限制保障更直接——你不用担心一个服务器实例认为用户被限制了,而另一个却不这么认为的情况。

当我不确定哪个“看起来”更简单时,我用一个决胜标准: 简单的系统更稳定 。如果你比较一个软件系统的两种状态,其中一种在需求不变的情况下需要更多后续工作,那另一种就更简单。Redis需要部署和维护,可能会有自身故障,需要单独的监控,还要在服务遇到的所有新环境中独立部署,等等。因此,内存速率限制比Redis更简单⁵。

为什么不想追求可扩展性?

现在,某类工程师可能会在心里大喊:“但内存速率限制无法扩展!”采用一切可行的最简方案,绝对不会打造出互联网级别的超大规模系统。它只会打造出适应当前规模、运行良好的系统。这是不负责任的工程设计吗?

不。在我看来,大型科技SaaS工程的核心错误,就是对扩展性的过度痴迷。我见过太多因过度设计系统、为远超当前规模的流量做准备,而导致的不必要的痛苦。

不这么做的主要原因是 它行不通 。根据我的经验,对于任何非 trivial(非小型)的代码库,你都无法预判它在数倍流量下的表现,因为你事先不知道瓶颈会出在哪里。你最多只能确保能应对2倍或5倍的当前流量,然后再应对随之而来的问题。

另一个不这么做的原因是 它会让代码库变得僵化 。把服务拆分成两部分以便独立扩展,这很有趣(我见过这种情况大概十次,其中真正实现了有效独立扩展的只有一次)。但这会让很多功能变得极难实现,因为它们现在需要跨网络协调。最糟的情况下,还需要跨网络事务,这是个真正棘手的工程问题。大多数时候,你根本没必要做这些!

最终思考

我在科技行业待得越久,就越不乐观地认为我们能预判系统的发展方向。搞清楚系统当前的状态就已经够难了。而实际上,做好设计的主要实际困难,正是准确地整体理解系统。大多数设计都是在没有这种理解的情况下完成的,因此大多很糟糕。

软件开发大致有两种方式。第一种是预测未来六个月或一年的需求,然后为此设计最优系统。第二种是为当前的实际需求设计最优系统:换句话说,采用一切可行的最简方案。

编辑:这篇文章在Hacker News上引发了一些讨论。

其中一个有趣的讨论线程thread认为,架构的简单性在大规模下无关紧要,因为“实现中的状态空间探索”的复杂性(我认为这大概和我在这里写的内容类似)压倒了其他所有复杂性。我不同意——功能交互越复杂,简单的架构就越重要,因为你的“复杂性预算”几乎会耗尽。

我也要感谢Ward Cunningham和Kent Beck提出了这个表述——我真心以为是自己想到的这句话,不过几乎肯定是记起来了。哎呀!感谢HN用户ternaryoperator指出这一点。


  1. 1. 它只是Unix套接字(Unix sockets)和派生进程(forked processes)!我 超喜欢 Unicorn。 ↩
  2. 2. 每家科技公司都有某种边缘代理。 ↩
  3. 3. 我确实喜欢Puma,认为它是个优秀的Web服务器。在某些场景下,你会选择它而非Unicorn(不过在这些场景中,我个人会考虑用除Ruby之外的其他语言)。 ↩
  4. 4. 这里我受到了Rich Hickey的精彩演讲Simple Made Easy的影响。我并不完全认同其中所有观点(我认为熟悉度实际上确实会影响实际的简单性),但这个演讲绝对值得一看。 ↩
  5. 5. 当然,如果系统需要一定程度的水平扩展,内存速率限制就不适用了,必须换成类似Redis的方案。但根据我的经验,Golang服务可以大幅扩展,而无需水平扩展到过多的副本数。 ↩

#软件设计 #最简方案 #系统架构 #速率限制 #内存存储 #水平扩展 #YAGNI #代码简洁性