Good system design image


引言

在阮一峰的第 362 期科技周刊(https://www.ruanyifeng.com/blog/2025/08/weekly-issue-362.html)中看到了一篇被推荐的好文,内容是 GitHub 工程师 肖恩·戈德克(sean goedecke)(https://www.seangoedecke.com)谈系统设计,是他的经验总结,教大家设计一个良好的系统,不是空泛之谈。原标题是《关于好的系统设计我所知道的一切》(https://www.seangoedecke.com/good-system-design)。

sean goedecke avatar

我是看了周刊的一些摘录,觉得很好,挺吸引我,于是去看了原全文,真是写得很不错,很打动我,我觉得很有用,于是我迫不及待的分享,希望别人也知道,希望别人也能获得收获、启发。

今天这位 GitHub 高级工程师是我见过最名副其实的,深入浅出的把软件开发中的很多看似抽象与复杂的东西简单清晰的表达出来,一语中的,直达本质,真正意义的“干货”,几乎没有任何故弄玄虚的卖弄。

以下是从周刊中转载的摘要:

摘要

1、程序设计是组装代码,系统设计是组装服务。

程序设计的组件是变量、函数、类等,系统设计的组件是服务器、数据库、缓存、队列、事件总线、代理等。


2、如果一个系统很长时间不出错,它的设计就是良好的。

如果你进一步看了代码,脱口而出:”哈,这比我想的要简单”,或者”这个部分不用我操心,即使出问题也容易解决”,它的设计就是优秀的。


3、良好的系统设计,总是从一个有效的简单系统发展而来。千万不要从零开始设计一个复杂的系统。


4、系统设计的难点在于状态。尽量采用无状态组件,最小化”有状态组件”的数量。

状态的复杂性在于,你无法简单地重启服务。一旦出错,往往需要手动修复状态。


5、状态需要保存在数据库。数据库是最重要的系统组件,用来管理状态。

数据库的设计目标是每张表易于理解:打开看一下表结构,就能大致了解存储的数据内容及其原因。

千万不要采用复杂的表结构(也就是数据结构),会给代码带来极大的复杂性和性能约束。


6、数据库往往是系统瓶颈,因为每个页面请求可能要调用数十次、数百次数据库,而且是按顺序调用。

为了避免瓶颈,数据库可以做成一个写入节点和多个只读副本。数据查询都发往只读副本,数据写入发往写入节点。

写入节点与只读副本之间,存在数据复制延迟。如果更新一条记录后,你需要立即读取它,那么可以将数据放入内存,写入数据库成功后从内存读取。


7、耗时的操作要拆分出来,放在后台作业(即系统外部的单独服务),排队完成。

后台作业主要分成两个组件:一个队列服务,一个作业运行器(从队列中获取任务并执行)。

队列任务的软件,可以用 Redis(需要尽快执行的任务),也可以用数据库(不着急的任务)。


8、如果数据的生成速度和读取速度不匹配,经典解决方案就是缓存。

缓存的最简单做法,就是把数据保存在内存,否则就使用专门的键值存储软件(比如 Redis 或 Memcached),后者的好处是多个服务器可以共享缓存。

初级工程师希望缓存所有内容,而高级工程师希望尽量少用缓存。因为缓存是状态的来源,不可避免需要校验状态和处理状态过期。


9、除了缓存和后台作业,大型系统通常还有事件中心,一般用的是 Kafka。

事件中心也是一个队列,存放的是”某件事发生了”的消息。比如,用户注册触发了”新帐户创建”事件,该事件就放入事件中心,然后由事件中心去通知订阅该事件的多个服务:发送欢迎电子邮件、设置个人空间等等。

事件中心适用于,发送事件的代码不关心其他服务如何处理事件,或者事件量很大且对响应时间不太敏感。

不要过度使用事件,很多时候,更简单的做法是让一个服务请求另一个服务的 API。

为了便于除错,所有日志最好都放在一起,你可以立即看到另一个服务的响应。


10、推拉

如果数据需要传送到多处,有拉取(pull)和推送(push)两种选择。

一般来说,拉取比较简单(比如大多数网站采用的轮询),推送更节省资源,不需要用户主动请求数据,一旦后端数据发生变化,服务器主动将数据推送给每个客户端。

如果你确实需要向 100 万个客户端提供最新数据(就像 Gmail 那样),应该采用推送还是拉取?这要视情况而定。如果采用推送,就要把每次推送放入一个事件队列,并让一大群事件处理器从队列中拉取数据并推送。如果采用拉取,就要部署一堆(比如 100 台)快速的只读缓存服务器,处理所有读取流量。

以下是我结合 AI 转载并翻译的 肖恩·戈德克 的博客原文:

译文


关于好的系统设计我所知道的一切

  • 肖恩·戈德克 (sean goedecke)
  • 2025 年 6 月 21

我看过很多糟糕的系统设计建议。一个经典例子是 LinkedIn 上那种“你肯定没听说过队列”式的帖子,显然是给刚入行的新人看的。另一种是 Twitter 上那种“如果你把布尔值存进数据库,你就是个糟糕工程师”式的聪明话1。即使一些好的系统设计建议,也可能有点糟糕。我非常喜欢《设计数据密集型应用》,但我不觉得它对大多数工程师实际遇到的系统设计问题特别有用。

什么是系统设计?在我看来,如果软件设计是关于如何组装代码行,那么系统设计就是关于如何组装服务。软件设计的基本单元是变量、函数、类等。系统设计的基本单元是应用服务器、数据库、缓存、队列、事件总线、代理等。

这篇文章是我试图用大体框架,把我所知道的关于好的系统设计的所有东西写下来。很多具体的判断确实需要经验,这点我在这篇文章里无法传达。但我尽量写下我能写的东西。

识别好的设计

好的系统设计看起来是怎样的?我之前写过,它看起来并不起眼。在实践中,它看起来就是长时间没有任何问题。如果你有“哇,这比我预期的要简单”或者“我永远不需要考虑系统的这部分,它运行得很好”这样的想法,就能知道你遇到了好的设计。反过来说,好的设计是低调的:糟糕的设计往往更引人注目。我对那些看起来很令人印象深刻的系统总是持怀疑态度。如果一个系统有分布式共识机制、多种不同形式的事件驱动通信、CQRS 以及其他一些巧妙的技巧,我会怀疑是否有某个根本性的糟糕决策正在被弥补(或者系统只是简单地进行过度设计)。

我经常独自思考这个问题。工程师们看到包含许多有趣部分的复杂系统,会想:“哇,这里面居然有这么多系统设计!” 事实上,复杂系统通常反映出缺乏良好的设计。我说“通常”是因为有时你确实需要复杂的系统。我开发过许多系统,它们本身就很复杂。然而,一个有效的复杂系统总是从一个有效的简单系统发展而来。从零开始开发一个复杂的系统是一个非常糟糕的主意。

有状态和无状态

软件设计的难点在于状态。如果你要存储任何类型的信息,无论存储多长时间,你都需要做出许多关于如何保存、存储和提供这些信息的棘手决策。如果你不存储信息2,你的应用就是“无状态的”。举个不寻常的例子,GitHub 有一个内部 API,它接收 PDF 文件并返回其 HTML 渲染结果。这是一个真正的无状态服务。任何写入数据库的操作都是有状态的。

您应该尝试在任何系统中最小化有状态组件的数量。 (从某种意义上说,这是显而易见的,因为您应该尝试最小化系统中所有组件的数量,但是有状态组件特别危险。)您应该这样做的原因是有状态组件可能会陷入不良状态。只要您执行广泛合理的操作,我们的无状态 PDF 渲染服务就会永远安全运行:例如,在可重启的容器中运行它,这样如果出现任何问题,它可以被自动终止并恢复到工作状态。有状态服务无法像这样自动修复。如果您的数据库中出现错误条目(例如,条目的格式会触发应用程序崩溃),您必须手动进入并修复它。如果您的数据库空间不足,您必须想办法修剪不需要的数据或扩展它。

这在实践中意味着,一个服务了解状态(即与数据库通信),其他服务执行无状态操作。避免五个不同的服务都写入同一张表。相反,让其中四个服务向第一个服务发送 API 请求(或发出事件),并将写入逻辑保留在该服务中。如果可以的话,读取逻辑也值得这样做,尽管我对此并不绝对。有时,服务快速读取表 user_sessions 比向内部会话服务发送慢两倍的 HTTP 请求更好。

数据库

由于状态管理是系统设计中最重要的部分,因此最重要的组件通常就是状态所在的位置:数据库。我大部分时间都在使用 SQL 数据库(MySQL 和 PostgreSQL),所以我接下来要讨论的就是这些。

模式和索引

如果您需要在数据库中存储数据,首先要做的就是定义一个包含所需模式的表。模式设计应该灵活,因为一旦拥有数千或数百万条记录,更改模式就会非常麻烦。但是,如果您将其设计得过于灵活(例如,将所有内容都放在 JSON 的“值”列中,或者使用“键”和“值”表来跟踪任意数据),则会给应用程序代码带来极大的复杂性(并且可能会带来一些非常棘手的性能限制)。在这里划清界限需要根据具体情况进行判断,但总的来说,我的目标是让我的表易于理解:您应该能够浏览数据库模式,并大致了解应用程序存储的内容及其原因。

如果您预计表的数据量会超过几行,则应该为其创建索引。尽量让你的索引匹配你最常用的查询(例如,如果你按 emailtype 查询,就创建一个包含这两个字段的索引)。索引就像嵌套的字典,所以确保把基数最高的字段放在最前面(否则每个索引查找都会必须扫描所有 type 的用户来找到那个拥有正确 email 的)。不要对你能想到的每一件事都添加索引,因为每个索引都会增加写入开销。

瓶颈

在高流量应用程序中,访问数据库通常是瓶颈。即使计算端效率相对较低(例如,Ruby on Rails 运行在像 Unicorn 这样的预分叉服务器上),情况也是如此。这是因为复杂的应用程序需要进行大量的数据库调用——每个请求都要进行数百次调用,而且通常是顺序进行的(因为在确认用户没有滥用权限之前,你不知道是否需要检查用户是否属于某个组织,等等)。如何避免瓶颈?

查询数据库时,直接查询数据库。让数据库执行操作几乎总是比自己执行更高效。例如,如果您需要来自多个表的数据,JOIN请直接查询它们,而不是分别进行查询,然后在内存中将它们拼接起来。尤其是在使用 ORM 时,要小心在内部循环中意外执行查询。那是一种很容易将一个 select id, name from table 变成 select id from table,以及一百个 select name from table where id = ? 的方式。

有时你确实需要拆分查询。这种情况并不常见,但我遇到过一些查询复杂到足以让数据库通过拆分它们而不是尝试作为一个单独的查询来执行更加轻松。我确信总能构建索引和提示,使得数据库能做得更好,但偶尔的战术性查询拆分是你工具箱中值得拥有的工具。

尽可能多地向数据库副本发送读取查询。典型的数据库设置将包含一个写入节点和多个读取副本。你越能避免从写入节点读取,就越好——那个写入节点已经足够忙于处理所有写入操作了。例外情况是当你实在无法忍受任何复制延迟时(因为读取副本总是至少落后写入节点几十毫秒)。但在大多数情况下,可以通过简单的技巧来规避复制延迟:例如,当你更新一条记录但需要立即使用它时,可以在内存中填充更新后的详细信息,而不是在写入后立即重新读取。

小心查询峰值(尤其是写查询,尤其是事务)。一旦数据库过载,速度就会变慢,从而进一步加剧数据库过载。事务和写入操作很容易导致数据库过载,因为它们需要为每个查询执行大量的数据库工作。如果您正在设计一个可能产生大量查询峰值的服务(例如某种批量导入 API),请考虑限制查询速度。

慢操作,快操作

有些服务必须快速完成某些操作。如果用户正在与某个对象(例如,API 或网页)交互,他们应该在几百毫秒内看到响应3。但服务必须执行其他一些缓慢的操作。有些操作确实需要很长时间(例如,将非常大的 PDF 转换为 HTML)。对此的一般模式是,将对用户有用的最少工作拆分出来,其余工作在后台完成。在 PDF 转 HTML 的示例中,您可以立即将第一页渲染为 HTML,并将其余工作排队到后台任务中。

什么是后台任务?值得详细解释这个问题,因为“后台任务”是系统设计的一个核心基础。每家科技公司都会有一些用于运行后台任务的系统。主要有两个组件:一组队列,例如在 Redis 中,以及一个任务执行服务,它会从队列中获取项目并执行它们。你通过将类似 {job_name, params} 的项目放入队列中来加入后台任务。也可以安排后台任务在设定时间运行(这对于周期性清理或汇总很有用)。后台任务应该是执行慢速操作的首选,因为它们通常是经过充分验证的成熟方案。

有时候你想自己搭建一个队列系统。例如,如果你想要将一个任务加入队列并在一个月后执行,你可能不应该把任务项放在 Redis 队列中。Redis 的持久化通常无法保证在这段时间内有效(即使能保证,你也可能想要以某种方式查询那些远期加入队列的任务,而这用 Redis 任务队列会有些棘手)。在这种情况下,我通常会创建一个数据库表来存储待处理的操作,表中包含每个参数的列以及一个 scheduled_at 列。然后,我使用一个每日任务来检查这些项目 scheduled_at <= today,并在任务完成后将它们删除或标记为完成。

缓存

有时候一个操作之所以缓慢,是因为它需要执行一个对用户来说成本高昂(即缓慢)且相同的任务。例如,如果你正在计算计费服务中向用户收取的费用,你可能需要调用 API 来查询当前价格。如果你是按使用量收费(比如 OpenAI 按 token 收费),这可能会(a)变得不可接受地缓慢,并且(b)给提供价格的服务带来大量流量。在这种情况下,经典的解决方案是缓存:每五分钟查询一次价格,并在期间存储该值。最简单的缓存方式是内存缓存,但使用像 Redis 或 Memcached 这样的快速外部键值存储也很流行(因为这意味着你可以将一个缓存共享到多个应用服务器上)。

典型的模式是初级工程师学习了缓存后想要缓存所有东西,而高级工程师则尽可能少地使用缓存。为什么会这样呢?这归结于我之前提到的关于状态性的危险。缓存是一种状态源。它可能会存储奇怪的数据,或者与实际情况不同步,或者通过提供过时的数据导致神秘的错误等等。在缓存任何东西之前,你都应该先认真努力地尝试加速它。例如,缓存一个没有数据库索引覆盖的昂贵 SQL 查询是愚蠢的。你应该直接添加数据库索引!

我经常使用缓存。一个有用的缓存技巧是使用定时任务和文档存储(如 S3 或 Azure Blob Storage)作为大规模持久缓存。如果你需要缓存一个非常昂贵的操作的结果(比如一个大客户的每周使用报告),你可能无法将结果放入 Redis 或 Memcached。相反,将带有时间戳的结果块(blob)放入你的文档存储中,并直接从那里提供文件。就像我上面提到的数据库支持的长期队列一样,这是一个在不使用特定缓存技术的例子中使用缓存概念的例子。

事件

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

你不应该过度使用事件。很多时候,一个服务向另一个服务发起 API 请求会更好:所有的日志都在同一个地方,更容易理解,而且你可以立即看到另一个服务返回了什么。当发送事件的代码不一定关心消费者如何处理事件,或者当事件是高容量且不太有时间敏感性(例如每个新推文上的滥用扫描)时,事件是合适的。

推送和拉取

当你需要数据从一个地方流向很多其他地方时,有两种选择。最简单的是拉取。这是大多数网站的工作方式:你有一个拥有某些数据的服务器,当用户想要这些数据时,他们会通过浏览器向服务器发起请求,将数据拉取到他们那里。这里的问题是用户可能会大量拉取相同的数据——例如,刷新他们的电子邮件收件箱查看是否有新邮件,这会导致整个网络应用程序被拉取和重新加载,而不是仅仅加载有关电子邮件的数据。

另一种选择是推送。与其让用户请求数据,不如让他们注册为客户端,然后在数据发生变化时,服务器将数据推送给每个客户端。这就是 GMail 的工作方式:你不必刷新页面来获取新邮件,因为它们会在到达时直接出现。

如果我们讨论的是后台服务而不是使用浏览器的用户,很容易理解为什么推送是个好主意。即使在一个非常大的系统中,你可能只需要为大约一百个服务提供相同的数据。对于变化不大的数据,每次数据变化时发送一百个 HTTP 请求(或 RPC,或其他方式)要容易得多,而不是每秒提供相同数据一千次。

假设你确实需要为百万级客户端(比如 GMail)提供实时数据。这些客户端应该是推送还是拉取?这取决于具体情况。无论如何,你都无法从单个服务器上运行所有服务,因此需要将其分配给系统的其他组件。如果你选择推送,这意味着每个推送都会被放在事件队列上,并有一群事件处理器从队列中拉取并发送你的推送。如果你选择拉取,这意味着你需要启动一批(比如一百个)快速4读副本缓存服务器,这些服务器将位于你的主要应用程序前面,处理所有读流量5

热点路径

设计系统时,用户与系统交互的方式多种多样,数据也可通过多种方式流经系统。这可能会让人有点不知所措。关键在于主要关注“热路径”:系统中最重要的部分,以及处理最多数据的部分。例如,在计量计费系统中,这些部分可能是决定是否向客户收费的部分,也可能是需要关联平台上所有用户操作以确定收费金额的部分。

热点路径之所以重要,是因为它们比其他设计领域拥有更少的可能解决方案。你可以用成千上万种方法构建计费设置页面,它们基本上都能正常工作。但可能只有少数几种方法能合理地处理用户行为的洪流。热点路径也更容易出错。你必须犯下严重的错误才会导致整个产品瘫痪,但任何在所有用户行为上触发的代码都可能轻易引发巨大问题。

日志和指标

你怎么知道你遇到了问题?我从我最偏执的同事那里学到的经验是,在出现问题的路径上要积极记录日志。如果你正在编写一个检查多个条件以确定用户端点是否应返回 422 状态的函数,你应该记录被触发的条件。如果你在编写计费代码,你应该记录每个做出的决策(例如:“我们因为 X 原因不对此事件计费”)。许多工程师不这样做,因为它增加了大量的日志模板代码,并使得编写优美优雅的代码变得困难,但你仍然应该这样做。当有重要客户抱怨收到 422 状态时,你会庆幸自己这样做——即使那个客户确实做错了事,你仍然需要弄清楚他们具体做错了什么。

你也应该对系统的运行部分有基本的可观测性。这意味着主机或容器的 CPU/内存使用情况、队列大小、每个请求或每个任务的平均时间等。对于面向用户的指标,如每个请求的时间,你还需要关注 p95 和 p99(即你最慢的请求有多慢)。即使只有一个或两个非常慢的请求也很可怕,因为它们主要来自你的最大和最重要的用户。如果你只看平均值,很容易错过有些用户发现你的服务无法使用的实际情况。

断路器、重试和优雅失败

我写了一整篇文章关于断路器(紧急停止开关),但在这里不会重复,核心思想是你要仔细考虑系统严重失败时会发生什么。

重试并非万能药。你需要确保不要因为盲目重试失败的请求而给其他服务增加额外负载。如果可能的话,将高流量 API 调用放在“断路器”中:如果你连续收到太多 5xx 响应,就暂停一段时间不发送请求,让服务恢复。你还需要确保不要重试那些可能成功也可能失败的写入事件(例如,如果你发送一个“向此用户收费”的请求并收到 5xx 响应,你不知道用户是否已被收费)。解决这个问题的一个经典方法是使用“幂等性密钥”,这是一个在请求中使用的特殊 UUID,其他服务用它来避免重新运行旧请求:每次它们执行操作时,都会保存幂等性密钥,如果收到带有相同密钥的另一个请求,它们会默默地忽略它。

同时,也需要决定当系统部分出现故障时会发生什么。例如,假设你有一些速率限制代码,该代码检查 Redis 桶以查看用户在当前窗口中是否发送了过多请求。当 Redis 桶不可用时会发生什么?你有两个选择:允许请求通过(fail open),或者阻止请求并返回 429 状态码(fail closed)。

是否应该在故障时保持开放或关闭取决于具体功能。在我看来,速率限制系统几乎应该始终保持开放。这意味着速率限制代码的问题不一定会导致重大的用户事件。然而,身份验证(显然)应该始终保持关闭:拒绝用户访问自己的数据比允许用户访问其他用户的数据要好。很多情况下,我们并不清楚什么是正确的行为。这通常是一个艰难的权衡。

最后的想法

有些话题我特意没有在这里讨论。例如,是否以及何时将单体应用拆分成不同的服务,何时使用容器或虚拟机,追踪,以及良好的 API 设计。部分原因是我觉得这些不太重要(根据我的经验,单体应用就没问题),或者我觉得这些话题太明显了(你应该使用追踪),或者因为我没时间(API 设计很复杂)。

我想要表达的主要观点正如我在本文开头所说:好的系统设计并非关于巧妙的技巧,而是关于知道如何在正确的位置使用枯燥但经过充分测试的组件。我不是水管工,但我想好的水管工程也类似:如果你做事情过于花哨,最终可能会弄得一身狼藉。

尤其是在大型科技公司,这些组件已经是现成的(例如,你的公司已经有某种事件总线、缓存服务等等),好的系统设计看起来毫无意义。很少有领域会让你想做那种可以在会议上大谈特谈的系统设计。这样的系统设计确实存在!我见过手工编写的数据结构让原本不可能实现的功能成为可能。但十年来我只见过一两次。我每天都会看到枯燥乏味的系统设计。


参考链接


  1. 您应该存储时间戳,并将时间戳的存在视为 true。我有时会这样做,但并非总是如此——在我看来,保持数据库模式的可读性是有一定价值的。 

  2. 从技术上讲,任何服务都会在一段时间内存储某种类型的信息,至少是在内存中。通常,这里指的是将信息存储在请求-响应生命周期之外(例如,持久存储在磁盘上的某个位置,例如数据库中)。如果您只需启动应用服务器就能启动新版本的应用,那么这就是无状态应用。 

  3. Twitter 上的游戏开发者会说,任何低于 10 毫秒的响应都是不可接受的。无论这种说法是否正确,但事实上,成功的科技产品并非如此——如果应用正在执行对用户有用的操作,用户是可以接受较慢的响应速度的。 

  4. 它们之所以快,是因为它们不需要像主服务器那样与数据库进行交互。理论上,这可以只是一个存储在磁盘上的静态文件,当被请求时,它们就将其提供出来,甚至可以是存储在内存中的数据。 

  5. 顺便说一句,那些缓存服务器要么轮询你的主服务器(即拉取),要么你的主服务器会向它们发送新数据(即推送)。我不认为哪种做法差别太大。推送能让你获得更实时的数据,但拉取更简单。