用最简单可行的方法
在设计软件系统时,用最简单可行的方法。
你会惊讶地发现,这条建议的适用范围有多广。我真的认为,你任何时候都可以这么做。无论是修复 bug、维护现有系统,还是架构新系统,这个方法都适用。
很多工程师在设计时,总想构建一个“理想”的系统:结构清晰、可无限扩展、优雅地分布式等等。我认为,这完全是软件设计的歧途。我们应该把时间花在深入理解现有系统上,然后,用最简单可行的方法解决问题。
简单,可能看起来平平无奇
系统设计需要工程师熟练掌握很多工具:应用服务器、代理、数据库、缓存、队列等等。当新手工程师熟悉了这些工具后,他们自然很想用上它们。用各种不同的组件来搭建系统,本身就很有趣!在白板上画着各种方框和箭头,感觉自己就像个真正的工程师。
然而,就像很多技能一样,真正的精通,往往在于懂得何时该“少做”,而不是“多做”。这就像武侠电影里,一个雄心勃勃的新手和一个老练的大师对决的经典场面:新手上蹿下跳,动作花哨;而大师则沉稳如山,静待时机。结果,新手的攻击总是差那么一点,而大师一出手,便是决定胜负的一击。
在软件领域,这意味着优秀的软件设计看起来平平无奇。它看起来好像没什么大不了的。当你接触到一个伟大的软件设计时,你可能会想:“哦,原来这个问题这么简单”,或者“太好了,根本不用做什么复杂的事”。
Unicorn 就是一个伟大的软件设计,因为它借助 Unix 的基本功能,就实现了 Web 服务器最重要的几个保证(请求隔离、水平扩展、崩溃恢复)。行业标准的 Rails REST API 也是一个伟大的软件设计,因为它用最“无聊”的方式,恰好满足了你开发一个 CRUD 应用所需要的一切。我不认为这些软件本身有多么惊艳,但它们是设计上的杰作,因为它们都用了最简单可行的方法。
你也应该这样做!假设你有一个用 Golang 写的应用,想给它增加某种限流(rate limiting)功能。最简单可行的方法是什么?你可能首先会想到,加一个持久化存储(比如 Redis),用漏桶算法来追踪每个用户的请求次数。这当然行得通!但你真的需要引入一个全新的基础设施吗?如果把每个用户的请求次数保存在内存里呢?当然,应用重启时会丢失一些限流数据,但这重要吗?再想想,你确定你的边缘代理(edge proxy)本身就不支持限流吗?也许你根本不需要自己实现这个功能,只要在配置文件里写几行代码就够了?
也许你的边缘代理确实不支持限流。也许你不能把数据存在内存里,因为你并行运行的服务器实例太多了,导致最严格的限流策略也形同虚设。也许丢失限流数据是绝对不能接受的,因为你的服务正被人疯狂攻击。在这些情况下,最简单可行的方法就是增加持久化存储,那你就应该这么做。但如果那些更简单的方法可行,你难道不想用吗?
你完全可以从零开始,用这种方式构建一整个应用:先从最绝对简单的方法开始,只有当新的需求迫使你扩展时,你才去扩展它。这听起来有点傻,但它确实有效。你可以把这看作是把 YAGNI (You Ain't Gonna Need It)(你不会需要它的)原则奉为终极设计准则:它的优先级高于单一职责、高于选择最合适的工具,甚至高于所谓的“好设计”。
用最简单的方法,有什么问题?
当然,总是用最简单可行的方法,也有三个大问题。第一,由于没有预见未来的需求,你最终可能会得到一个僵化的系统,或者一个“大泥球”(big ball of mud)。第二,“最简单”的定义并不清晰,最坏的情况下,我等于是在说“要想设计好,就得做好设计”。第三,你应该构建能够扩展的系统,而不是仅仅满足当前需求的系统。我们来逐一分析这些反对意见。
大泥球
对一些工程师来说,“用最简单可行的方法”听起来就像是在告诉他们别搞工程了。如果最简单的方法通常只是一个临时的补丁(kludge),这是否意味着这个建议最终会导致系统一团糟?我们都见过那种补丁摞补丁的代码库,它们绝对算不上好设计。
但补丁真的简单吗?我其实不这么认为。一个补丁或临时方案的问题恰恰在于它不简单:它给代码库增加了复杂性,让你必须时刻记住又多了一个“坑”。补丁只是更容易想到而已。要找到真正的修复方案,往往需要你理解整个(或大部分)代码库,这很难。实际上,真正的修复方案几乎总是比补丁简单得多。
用最简单可行的方法,并不容易。当你面对一个问题时,最先想到的几个解决方案,很可能不是最简单的。要找到最简单的方案,你需要考虑许多不同的方法。换句话说,这本身就需要做工程设计。
什么是简单?
关于什么样的代码才算简单,工程师们经常争论不休。如果“最简单”本身就意味着“好设计”,那么说“你应该用最简单可行的方法”是不是就成了一句废话?换句话说,Unicorn 真的比 Puma 简单吗?用内存限流真的比用 Redis 简单吗?这里有一个粗略但直观的定义:
简单的系统“活动部件”更少:你在使用它时,需要考虑的东西更少。
简单的系统内部耦合更少。它是由具有清晰、直接接口的组件构成的。
Unix 进程比线程简单(因此 Unicorn 比 Puma 简单),因为进程之间的耦合更少:它们不共享内存。这对我来说很有道理!但我认为这并不能帮你判断所有情况下的简单性。
那么内存限流和 Redis 哪个更简单呢?一方面,内存方案更简单,因为你不需要考虑部署一个独立的、带持久化内存的服务所涉及的所有事情。但另一方面,Redis 更简单,因为它提供的限流保证更直接——你不用担心一个服务器实例认为用户被限流了,而另一个实例却不这么认为的情况。
当我不确定哪个“看起来”更简单时,我喜欢用这个标准来做决定:简单的系统是稳定的。如果你在比较一个软件系统的两种状态,其中一种状态在没有新需求的情况下需要更多持续的维护工作,那么另一种状态就更简单。Redis 必须部署和维护,它本身可能出故障,需要自己的监控,而且服务每到一个新环境,都需要单独部署一次。因此,内存限流比 Redis 更简单。
为什么不追求可扩展性?
听到这里,某些类型的工程师可能已经在心里尖叫了:“但内存限流无法扩展!” 的确,用最简单可行的方法,绝对不会让你得到一个最具“互联网规模”(web-scale)的系统。它只会让你得到一个在当前规模下运行良好的系统。这是一种不负责任的工程做法吗?
不。在我看来,大型科技公司 SaaS 工程的一个原罪就是对规模的痴迷。我见过太多因为过度设计系统,为超出当前规模好几个数量级的未来做准备,而导致的本可避免的痛苦。
不应该这么做的主要原因是,它根本行不通。根据我的经验,对于任何有点复杂的代码库,你都无法预测它在流量增加几个数量级后的表现,因为你无法提前知道所有的瓶颈会出现在哪里。你最多只能确保为当前流量的 2 倍或 5 倍做好准备,然后随时准备解决出现的问题。
不应该这么做的另一个原因是,它会让你的代码库变得僵化。将你的服务拆分成两个部分,以便它们可以独立扩展,这听起来很有趣(我见过大概十次这样的事,但真正有用地独立扩展的,可能只有一次)。但这样做会让某些功能的实现变得非常困难,因为它们现在需要跨网络进行协调。在最坏的情况下,它们甚至需要跨网络进行事务(transactions),这是一个真正困难的工程问题。而大多数时候,你根本不需要做这些!
结语
我在科技行业工作的时间越长,就越不看好我们预测一个系统未来走向的集体能力。仅仅是搞清楚一个系统目前的状况,就已经够难的了。而这,实际上也正是做好设计的主要实践困难:对系统有一个准确的、全局性的理解。大多数设计都是在缺乏这种理解的情况下完成的,因此,大多数设计都相当糟糕。
总的来说,开发软件有两种方式。第一种是预测你六个月或一年后的需求会是什么样,然后为此设计出最好的系统。第二种是为你现在的实际需求设计最好的系统:换句话说,就是用最简单可行的方法。
编辑后记:这篇文章在 Hacker News 上引发了一些讨论。
一个有趣的讨论串认为,在规模面前,架构的简单性并不重要,因为“实现中的状态空间探索”(我理解这大概是我在这里写过的内容)的复杂性会压倒其他任何复杂性。我不同意——当你的功能交互变得越复杂,一个简单的架构就变得越重要,因为你的“复杂性预算”几乎已经用完了。
我还要感谢 Ward Cunningham 和 Kent Beck 发明了这个说法——我真的以为是我自己想出来的,但几乎可以肯定我只是记住了它。哎呀!感谢 Hacker News 用户 ternaryoperator 指出这一点。
如果你喜欢这篇文章,可以考虑订阅我的新文章邮件更新,或者在 Hacker News 上分享它。
2025年8月28日 │ 标签: ,
[^1]: 它用的只是 Unix 套接字和 forked 进程!我太爱 Unicorn 了。
[^3]: 我确实喜欢 Puma,也认为它是一个很好的 Web 服务器。在某些用例中,你肯定会选择它而不是 Unicorn(尽管在那些情况下,我个人会认真考虑换一种语言,而不是用 Ruby)。
[^4]: 我在这里受到了 Rich Hickey 的精彩演讲 《Simple Made Easy》 的影响。我并不完全同意他的所有观点(我认为在实践中,熟悉度确实有助于简化),但这绝对值得一看。
[^5]: 当然,如果系统需要进行一定程度的水平扩展,内存限流就行不通了,必须换成 Redis 之类的东西。但根据我的经验,一个 Golang 服务可以扩展很多,而不需要水平扩展到超过少数几个副本。