我所知道的优秀系统设计的一切

我见过太多糟糕的系统设计建议了。一个经典案例,就是领英上那种为了博眼球而发的帖子,标题类似“打赌你从没听说过队列 (queues)”,大概是写给刚入行的新人的。另一个经典,是推特上那种“你要是敢在数据库里存布尔值,你就是个烂工程师”的小聪明[1]。甚至,连那些好的系统设计建议,有时候也挺糟糕的。我非常喜欢《设计数据密集型应用》这本书,但我并不认为它对工程师日常遇到的大多数系统设计问题有多大帮助。

那么,到底什么是系统设计?在我看来,如果说软件设计是如何组织一行行代码,那么系统设计就是如何组装一个个服务 (services)。软件设计的基本元素是变量、函数、类等等。而系统设计的基本元素,则是应用服务器 (app servers)、数据库 (databases)、缓存 (caches)、队列 (queues)、事件总线 (event buses)、代理 (proxies) 等等。

这篇文章,就是我试图用最通俗的语言,写下我所知道的关于优秀系统设计的一切。当然,很多具体的判断最终还是要靠经验,这是文章无法传授的。但我会尽力把我能写下来的都写下来。

识别好的设计

一个好的系统设计是什么样子的?我之前写过,它看起来平平无奇。在实践中,它就像一个很长很长时间里什么错都没出的系统。当你产生类似“咦,这事儿比我想象的要简单”或者“我从来不用操心系统的这个部分,它一直好好的”这样的念头时,你就知道自己正身处一个优秀的设计之中。矛盾的是,好的设计总是很低调:坏的设计反而常常比好的设计更“亮眼”。我总是对那些看起来很厉害的系统心存疑虑。如果一个系统用上了分布式共识机制、多种事件驱动通信、CQRS(命令查询责任分离模式)以及其他各种花哨的技巧,我就会怀疑,这是不是为了弥补某个根本性的错误决策(或者,它就是被赤裸裸地过度设计了)。

在这点上,我常常是孤独的。工程师们看到那些由许多有趣部分组成的复杂系统,会觉得:“哇,这里面肯定有很多牛逼的系统设计!” 但事实上,一个复杂的系统通常反映了优秀设计的缺失。我说“通常”,是因为有时候你确实需要复杂的系统。我曾参与过许多项目,它们的复杂性是业务需求所决定的,是“挣来的”。然而,一个能正常工作的复杂系统,永远是从一个能正常工作的简单系统演化而来的。从零开始直接构建一个复杂的系统,是个非常糟糕的主意。

状态与无状态

软件设计中最难的部分就是状态 (state)。如果你需要把任何信息存储一段时间,你就得做出一大堆棘手的决定:如何保存、如何存储、如何提供给外界。如果你不存储信息[2],那么你的应用就是“无状态的” (stateless)。举一个不那么简单的例子,GitHub 有一个内部 API,输入一个 PDF 文件,就能返回它的 HTML 渲染版本。这就是一个真正的无状态服务。任何会向数据库写入数据的服务,都是“有状态的” (stateful)。

你应该尽量减少任何系统中有状态组件的数量。(从某种意义上说,这简直是句废话,因为你本就该减少系统里所有组件的数量,但有状态的组件尤其危险。)为什么要这么做?因为有状态的组件可能会进入一个糟糕的状态。我们那个无状态的 PDF 渲染服务,只要你做的事情大体上靠谱(比如,把它放在一个可自动重启的容器里,这样出了问题就能被自动杀死并恢复正常),它就能安全地永远运行下去。但有状态的服务就没法这么简单地自动修复了。如果你的数据库里出现了一条坏数据(比如,这条数据的格式会导致你的应用程序崩溃),你就必须手动介入去修复它。如果你的数据库空间满了,你就得想办法清理掉不需要的数据,或者给它扩容。

这在实践中意味着,最好让一个服务来管理状态——也就是说,由它来和数据库打交道——而让其他服务去做那些无状态的事情。要避免让五个不同的服务都去写同一张表。正确的做法是,让其中四个服务向第一个服务发送 API 请求(或发出事件),把写入的逻辑都集中在那一个服务里。如果可以的话,读取的逻辑也最好这么做,尽管在这点上我没那么绝对。有时候,让服务直接快速读取一下 user_sessions 这张表,确实比发起一个慢一倍的 HTTP 请求去调用内部的会话服务要好。

数据库

既然管理状态是系统设计最重要的部分,那么最重要的组件通常就是状态的栖身之所:数据库。我的大部分职业生涯都在和 SQL 数据库(MySQL 和 PostgreSQL)打交道,所以接下来我主要谈论它们。

模式和索引

如果你需要在数据库里存东西,第一步就是定义一个表,并设计好它的模式 (schema)。模式设计应该足够灵活,因为一旦你有了成千上万条记录,再去修改模式就会变得极其痛苦。然而,如果你把它设计得过于灵活(比如,把所有东西都塞进一个叫 “value” 的 JSON 字段里,或者用 “keys” 和 “values” 两张表来追踪任意数据),你就会把大量的复杂性转移到应用程序代码中(而且很可能给自己带来一些非常尴尬的性能限制)。如何把握这个度是一个需要经验和具体情况具体分析的判断。但总的来说,我的目标是让我的表结构是人类可读的:你应该能通过浏览数据库的模式,大致了解这个应用在存储什么,以及为什么这么存。

如果你预估你的表会超过几行数据,你就应该给它加上索引 (indexes)。尽量让你的索引和你最常用的查询语句相匹配(比如,如果你经常按 emailtype 来查询,就创建一个包含这两个字段的索引)。索引的工作原理有点像嵌套的字典,所以记得把基数最高(也就是不同值的数量最多)的字段放在前面(否则,每次索引查找都不得不扫描所有相同 type 的用户,才能找到那个 email 正确的用户)。不要把你所有能想到的字段都加上索引,因为每个索引都会增加写入的开销。

瓶颈

在高流量应用中,访问数据库通常是性能瓶颈 (bottleneck)。即使计算端的效率相对较低(比如,用 Ruby on Rails 跑在一个像 Unicorn 这样的预派生(preforking)服务器上),情况也是如此。这是因为复杂的应用需要进行大量的数据库调用——常常是每个请求背后都有成百上千次的调用,而且很多是串行的(因为你得先确认一个用户没有滥用行为,才能知道是否需要检查他是否属于某个组织,等等)。那么,如何避免被数据库拖后腿呢?

查询数据库时,就让数据库干活。让数据库来完成工作,几乎总是比你自己动手更高效。比如,如果你需要来自多张表的数据,就用 JOIN 把它们连接起来,而不是分别查询再在内存里拼接。尤其当你使用 ORM(对象关系映射)时,要小心别在内层循环里意外地发起了查询。这很容易就把一个 select id, name from table 查询,变成了一个 select id from table 再加上一百个 select name from table where id = ? 查询。

不过,偶尔你确实也需要把查询拆开。这种情况不常发生,但我确实遇到过一些查询写得太丑,以至于把它们拆分成几个小查询,比让数据库硬着头皮跑一个大查询要轻松得多。我相信,理论上总能通过构造合适的索引和提示(hints),让数据库做得更好,但偶尔战术性地拆分查询,也是你工具箱里应该有的一个工具。

尽可能把读查询发送到数据库的只读副本 (replicas) 上。一个典型的数据库配置会有一个写节点和一堆只读副本。你能越少从写节点读取数据越好——那个写节点已经够忙了,要处理所有的写入操作。唯一的例外是,当你真的、真的无法容忍任何复制延迟时(因为只读副本总是会比写节点落后那么几毫秒)。但在大多数情况下,复制延迟可以通过一些简单的小技巧来绕过:比如,当你更新了一条记录但马上又要用它时,你可以在内存中填上更新后的细节,而不是在写入后立即重新读取。

要警惕查询的尖峰(特别是写查询,尤其是事务)。一旦数据库过载,它就会变慢,这又会让它更加过载。事务和写入操作特别容易让数据库过载,因为它们每次查询都需要数据库做很多工作。如果你正在设计一个可能会产生大量查询尖峰的服务(比如,某种批量导入的 API),考虑一下对你的查询进行限流。

慢操作与快操作

一个服务有些事情必须做得快。如果用户正在与某个东西互动(比如一个 API 或一个网页),他们应该在几百毫秒内看到响应[3]。但一个服务也必须做一些慢的事情。有些操作就是需要很长时间(比如,把一个非常大的 PDF 转换成 HTML)。通用的模式是,把能为用户做些有用事情所需的最少量工作分离出来,然后把剩下的工作放到后台去做。在 PDF 转 HTML 的例子中,你可能会立即渲染第一页的 HTML,然后把剩下的页面转换任务放进一个后台任务 (background job) 队列里。

什么是后台任务?这个问题值得详细回答,因为“后台任务”是系统设计的一个核心基本元素。每家科技公司都会有某种运行后台任务的系统。它通常包含两个主要部分:一组队列,比如放在 Redis 里;以及一个任务执行器服务,它会从队列里取出任务并执行它们。你通过把一个类似 {job_name, params} 的条目放进队列来“入队”一个后台任务。后台任务也可以被调度在某个设定的时间运行(这对于定期的清理工作或汇总报告非常有用)。对于慢操作,后台任务应该是你的首选方案,因为这通常是一条被无数人走过、非常成熟的路径。

有时候,你可能想自己动手实现一个队列系统。例如,如果你想让一个任务在一个月后执行,你可能不应该把它放进 Redis 队列里。Redis 的数据持久性通常无法保证那么长的时间(即便能,你可能也希望能够查询那些遥远的未来任务,而用 Redis 的任务队列来实现会很棘手)。在这种情况下,我通常会为待处理的操作创建一个数据库表,表的列包含每个参数以及一个 scheduled_at 列。然后,我用一个每日执行的任务来检查 scheduled_at <= today 的条目,并在任务完成后删除它们或标记为已完成。

缓存

有时候一个操作很慢,是因为它需要执行一个昂贵的(也就是慢的)任务,而这个任务的结果在不同用户之间是相同的。例如,如果你在一个计费服务中计算要向用户收取多少费用,你可能需要调用一个 API 来查询当前的价格。如果你是按使用量向用户收费(就像 OpenAI 按 token 收费那样),这可能会 (a) 慢得让人无法接受,并且 (b) 给提供价格的服务带来巨大的流量压力。这里的经典解决方案就是缓存 (caching):每五分钟才查询一次价格,并在期间把这个值存储起来。最简单的缓存方式是在内存中,但使用像 Redis 或 Memcached 这样快速的外部键值存储也很流行(因为这意味着你可以在一堆应用服务器之间共享一个缓存)。

一个典型的现象是,初级工程师学了缓存之后,就想把所有东西都缓存起来,而高级工程师则希望尽可能少地使用缓存。为什么会这样呢?这又回到了我一开始提到的有状态的危险性上。缓存是一种状态源。它里面可能会存入奇怪的数据,或者与真实数据不同步,或者因为提供过时的数据而引发神秘的 bug,等等。在没有认真尝试过加速某个操作之前,你永远不应该去缓存它。例如,为一个没有数据库索引覆盖的昂贵 SQL 查询做缓存是很傻的。你应该直接加上那个数据库索引!

我经常使用缓存。工具箱里一个很有用的缓存技巧是,使用一个定时任务和一个像 S3 或 Azure Blob Storage 这样的文档存储,来做一个大规模的持久化缓存。如果你需要缓存一个极其昂贵操作的结果(比如,为一个大客户生成每周的使用报告),你可能无法把结果塞进 Redis 或 Memcached。取而代之,你可以把带时间戳的结果文件存到你的文档存储里,然后直接从那里提供文件。就像我上面提到的用数据库实现长期队列一样,这是运用缓存的思想,而不拘泥于特定的缓存技术。

事件

除了某种缓存基础设施和后台任务系统,科技公司通常还会有一个事件中心 (event hub)。最常见的实现是 Kafka。事件中心其实就是一个队列——就像后台任务的队列一样——但你放进队列的不是“用这些参数运行这个任务”,而是“这件事发生了”。一个经典的例子是,每当有新账户创建时,就发出一个“新账户已创建”的事件,然后让多个服务消费这个事件并采取行动:一个“发送欢迎邮件”的服务,一个“扫描滥用行为”的服务,一个“为每个账户设置基础设施”的服务,等等。

你不应该过度使用事件。很多时候,让一个服务直接向另一个服务发起 API 请求会更好:所有的日志都在同一个地方,更容易推理,而且你能立刻看到另一个服务的响应是什么。事件适用于以下场景:发送事件的代码并不关心消费者会用这个事件做什么,或者事件量很大但对时间不那么敏感(例如,对每一条新的推文进行滥用行为扫描)。

推送与拉取

当你需要让数据从一个地方流向很多其他地方时,有两个选择。最简单的是拉取 (pull)。大多数网站就是这样工作的:你有一个服务器拥有某些数据,当用户想要这些数据时,他们(通过浏览器)向服务器发起一个请求,把数据拉取到自己这边。这里的问题是,用户可能会大量地拉取同样的数据——比如,不停地刷新邮箱收件箱看有没有新邮件,这会把整个 web 应用都拉下来重新加载,而不仅仅是关于邮件的数据。

另一种选择是推送 (push)。不是让用户来请求数据,而是让他们注册为客户端,然后当数据发生变化时,服务器把数据推送给每个客户端。Gmail 就是这样工作的:你不需要刷新页面就能看到新邮件,因为它们一到就会自动出现。

如果我们讨论的是后台服务而不是带着浏览器的用户,就很容易理解为什么推送是个好主意。即使在一个非常大的系统中,可能也只有一百个左右的服务需要同样的数据。对于不怎么变化的数据,每当数据变化时发起一百次 HTTP 请求(或者 RPC,或其他什么),要比每秒钟提供上千次同样的数据容易得多。

假设你确实需要向一百万个客户端提供最新的数据(就像 Gmail 那样)。这些客户端应该用推送还是拉取呢?这要看情况。无论哪种方式,你都无法用一台服务器搞定,所以你需要把它分派给系统的其他组件。如果你用推送,那很可能意味着把每一次推送都放进一个事件队列,然后用一大群事件处理器从队列里拉取任务并发送你的推送。如果你用拉取,那很可能意味着架设一大堆(比如一百台)快速的[4]只读缓存副本服务器,它们会挡在你的主应用前面,处理所有的读取流量[5]。

热路径

当你设计一个系统时,用户与它交互或数据流经它的方式有很多种。这可能会让你有点不知所措。诀窍是主要关注“热路径” (hot paths):系统中至关重要的部分,以及将处理最多数据的部分。例如,在一个按量计费的系统中,这些部分可能就是决定客户是否被收费的部分,以及需要接入平台上所有用户行为以确定收费金额的部分。

热路径很重要,因为它们的可行解决方案比其他设计领域要少。你可以用一千种方式来构建一个计费设置页面,而且它们基本上都能用。但能合理地消费海量用户行为数据流的方式可能就那么几种。热路径出问题时,后果也更严重。你得把一个设置页面搞得非常离谱,才能让整个产品宕机,但你写的任何会在所有用户行为上触发的代码,都可能轻易地引发巨大的问题。

日志与指标

你怎么知道系统有没有问题?我从我那些最偏执的同事那里学到的一件事是,在“不愉快路径”(happy path 的反义词)上要积极地记录日志 (logging)。如果你在写一个函数,它会检查一堆条件,看是否应该给一个面向用户的接口返回 422 错误,你就应该把命中的那个条件记录下来。如果你在写计费代码,你应该记录下每一个决策(比如,“我们没有为这个事件计费,因为 X 原因”)。很多工程师不这么做,因为它会增加一堆日志模板代码,让代码写起来不那么优雅漂亮,但你还是应该这么做。当一个重要客户抱怨他们收到了 422 错误时,你会庆幸自己当初这么做了——即使是客户自己做错了什么,你仍然需要帮他们搞清楚他们到底做错了什么

你还应该对系统的运行部分有基本的可见性。这意味着要有主机或容器的 CPU/内存、队列大小、平均请求/任务耗时等指标 (metrics)。对于像请求耗时这样面向用户的指标,你还需要关注 p95 和 p99(即最慢的那些请求有多慢)。哪怕只有一两个非常慢的请求也很可怕,因为它们极有可能来自你最大、最重要的用户。如果你只看平均值,就很容易忽略掉一些用户正发现你的服务根本没法用的事实。

熔断开关、重试和优雅降级

关于熔断开关 (killswitches),我写过一整篇文章,这里就不重复了,但主旨是,你应该仔细思考当系统发生严重故障时会发生什么。

重试 (retries) 不是万能灵药。你需要确保你不会因为盲目重试失败的请求,而给其他服务增加额外的负载。如果可以的话,把高流量的 API 调用放在一个“断路器” (circuit breaker) 内部:如果你连续收到太多 5xx 响应,就停止发送请求一段时间,让那个服务恢复一下。你还需要确保你不会重试那些可能成功也可能没成功的写操作(例如,如果你发送一个“给这个用户计费”的请求,然后收到了一个 5xx 错误,你不知道这个用户到底被计费了没有)。对此的经典解决方案是使用“幂等性密钥” (idempotency key),这是一个请求中特殊的 UUID,另一个服务用它来避免重复执行旧的请求:每次它们做某件事时,就保存这个幂等性密钥,如果它们又收到了一个带有相同密钥的请求,就默默地忽略它。

同样重要的是,要决定当你的系统某一部分发生故障时会发生什么。例如,假设你有一些限流代码,它会检查一个 Redis 桶,看一个用户在当前时间窗口内是否发了太多请求。当那个 Redis 桶不可用时会发生什么?你有两个选择:开放式失灵 (fail open),让请求通过;或者封闭式失灵 (fail closed),用一个 429 错误阻止请求。

你应该选择开放式还是封闭式失灵,取决于具体的功能。在我看来,一个限流系统几乎总是应该选择开放式失灵。这意味着限流代码的问题不一定会变成一个大的面向用户的事故。然而,认证(显然)总是应该选择封闭式失灵:拒绝一个用户访问他自己的数据,要好过让一个用户访问到其他人的数据。在很多情况下,正确的行为是什么并不明确。这通常是一个艰难的权衡。

最后的思考

有些话题我在这里故意没有涉及。例如,是否以及何时将你的单体应用 (monolith) 拆分成不同的服务,何时使用容器或虚拟机,链路追踪 (tracing),好的 API 设计。部分原因是我觉得它没那么重要(以我的经验,单体应用挺好的),或者我觉得它太显而易见了(你应该使用链路追踪),或者我只是没时间(API 设计很复杂)。

我想要表达的核心观点,就是我在这篇文章开头说的:好的系统设计不是关于花哨的技巧,而是关于知道如何在正确的地方使用那些无聊但经过充分检验的组件。我不是水管工,但我猜想好的管道工程也差不多:如果你在做什么太刺激的事情,你很可能会搞得自己满身都是屎。

尤其是在大型科技公司,这些组件已经作为现成的工具存在了(也就是说,你的公司已经有了某种事件总线、缓存服务等等),好的系统设计看起来会毫无波澜。只有极少数领域,你才需要去做那种可以在大会上演讲的系统设计。它们确实存在!我见过有人手写的复杂数据结构,让一些原本不可能实现的功能变成了可能。但在我十年的职业生涯中,这种情况我只见过一两次。而无聊的系统设计,我每一天都在见。


  1. 你应该用时间戳来代替,把时间戳的存在当作 true。我有时候会这么做,但不是每次都这样——在我看来,保持数据库模式的直接可读性也有其价值。

  2. 严格来说,任何服务都会在某个时间段内存储某种信息,至少在内存里是这样。这里通常指的是在请求-响应生命周期之外存储信息(例如,持久化地存在磁盘上的某个地方,比如数据库里)。如果你能通过简单地启动应用服务器就部署一个新版本的应用,那它就是一个无状态应用。

  3. 游戏开发者们会在推特上说,任何超过 10 毫秒的延迟都是不可接受的。不管这是否“应该”是事实,但对于成功的科技产品来说,这在事实上就是不成立的——如果应用在做对他们有用的事情,用户会接受更慢的响应。

  4. 它们之所以快,是因为它们不需要像主服务器那样和数据库打交道。理论上,这可以只是磁盘上的一个静态文件,在被请求时提供出去,甚至可以是保存在内存里的数据。

  5. 顺便说一句,那些缓存服务器要么会轮询你的主服务器(即拉取),要么你的主服务器会把新数据发送给它们(即推送)。我觉得你用哪种方式关系不大。推送会给你更实时的数据,但拉取更简单。