我看到很多糟糕的系统设计建议。较典型是:为领英优化过“你肯定没听说过队列”风格的文章,大概是针对刚入行的人。另一种则是为推特优化过的“如果你在数据库里存储布尔值,那你就是个糟糕的工程师”这类小把戏。即便是好的系统设计建议也可能存在不足。我很喜欢《数据密集型应用系统设计》,但我觉得它对于大多数工程师会遇到的系统设计问题来说,并不是特别有用。
什么是系统设计?在我看来,如果说软件设计是关于如何组合代码,那么系统设计就是关于如何组合服务。软件设计的基本元素是变量、函数、类等等。系统设计的基本元素则是应用服务器、数据库、缓存、队列、事件总线、代理等等。
这篇文章我以浅显的方式,写下关于优秀系统设计的一切。很多具体的判断确实需要经验,这是我无法在文章中传达的。但我会尽力写下我能写的内容。
认可优秀的设计
优秀的系统设计是什么样的?我之前写过,它看起来平淡无奇。实践中,它体现为系统长年稳定运行。当你产生”咦,这居然比预期简单得多”、”系统这个部分完全无需操心”的念头时,便意味着你正身处优秀的设计之中。不可思议的是,优秀的设计往往谦逊低调:糟糕的设计反而更令人印象深刻。对那些看似炫酷的系统,我始终抱有戒心。若某个系统同时包含分布式共识机制、多种事件驱动通信模式、CQRS 架构及其他设计模式,我总怀疑是否在弥补某些根本性设计缺陷(或者说就是过度设计)。
这种观点常使我显得格格不入。工程师们看到拥有诸多有趣组件的复杂系统时,往往会感叹”看啊,这里体现了多少系统设计的智慧!”但事实上,复杂的系统往往折射出优秀设计的缺失。我说”往往”是因为确实存在需要复杂系统的场景——我参与过许多配得上其复杂度的系统。然而,所有能正常运行的复杂系统,无一不是从能正常运行的简单系统演化而来。从零开始直接构建复杂系统,绝对是糟糕透顶的主意。
状态和无状态
软件设计中的难点在于“状态”问题。如果要存储任何类型的信息并使其长期保存,那么就需要对如何保存、存储和提供这些信息做出诸多复杂决策。如果没有进行信息存储,那么你的应用程序就是“无状态的”。举个不那么复杂的例子,GitHub 有一个内部 API,它能接收一个 PDF 文件并返回其对应的 HTML 格式呈现。这就是一个真正的无状态服务。任何写入数据库的操作都是有状态的。
在任何系统中,你都应该尽可能减少有状态组件的数量。(从某种意义上说这是不言而喻的,因为本就应该尽量减少系统中所有组件的数量,但有状态组件尤其危险。)这样做的原因在于,有状态组件可能陷入异常状态。只要我们采取基本合理的措施——例如将其运行在可重启容器中,当出现问题时能自动终止并恢复运行——无状态的PDF渲染服务就能永久安全运行。而有状态服务无法像这样自动修复。若数据库中出现异常条目(比如触发应用程序崩溃的格式错误条目),就必须手动介入修复;当数据库存储空间耗尽时,则需要设法清理无用数据或进行扩容。
这意味着在实际操作中,需要有一个服务能够了解状态信息——即它会与数据库进行交互——而其他服务则执行无状态操作。避免让五个不同的服务都向同一个表写入数据。相反,让其中四个服务向第一个服务发送 API 请求(或发出事件),并将写入逻辑留在这个服务中。如果可能的话,对于读取逻辑也需要这样做,尽管我对这一点不是那么绝对。有时让服务快速读取 user_sessions 表要比进行一次慢两倍的内部 HTTP 请求服务更好。
数据库
既然状态管理是系统设计中最重要的环节,那么最重要的组件通常就是存储状态的地方:数据库。大部分时间我都在使用 SQL 数据库(MySQL 和 PostgreSQL),接下来将重点讨论这类数据库。
模式和索引
在数据库中存储数据,首要任务是定义符合需求的表结构。模式设计应保持灵活性——因为当数据量达到数千或数百万条时,修改模式将变得极其困难。但若过度追求灵活性(例如将所有内容都存放在一个 JSON 列中,或者使用“键”和“值”表来记录任意数据,则会将大量复杂性转移至应用代码层(通常会引入一些非常棘手的性能问题)。如何权衡取决于具体场景的判断,但我的基本原则是保持表结构的人类可读性:通过浏览数据库模式,应该能大致理解应用程序存储的内容及其目的。
数据表有很多数据,就应当建立索引。索引应匹配最常用的查询模式(例如若常按邮箱和类型查询,就创建包含这两个字段的复合索引)。索引的工作原理类似于嵌套字典,因此务必让高频查询字段放在前面(否则每次索引查找都需扫描所有同类用户才能定位目标邮箱)。切忌盲目添加所有可能字段的索引——每个索引都会增加写入开销。
瓶颈
数据库层通常是高流量应用程序的瓶颈。即使计算方面的效率相对较低,也是如此。这是因为复杂应用需要执行大量数据库调用:每个请求往往需要执行数百次查询(比如需先确认用户不存在滥用行为,才能继续检查其是否属于某个组织,依此类推)。如何避免成为瓶颈?
进行数据库查询时,务必让数据库完成核心工作。几乎在所有情况下,让数据库执行操作都比自行处理更高效。例如需要多表数据时,应该使用 JOIN 操作而非分别查询后在内存中拼接。尤其在使用 ORM 时,要警惕在内部循环中意外触发查询——这极易将一句简单的 select id, name from table 转化为 select id from table 加上百次 select name from table where id = ? 查询。
有时确实需要将查询语句拆分开来。这种情况虽不常见,但确实存在某些复杂到极致的查询——与其强行作为单个查询执行,拆解后反而能减轻数据库负担。虽然理论上总能通过构建索引和提示让数据库更好处理,但偶尔进行这种查询拆分操作,也是可以的。
尽可能将读查询导向数据库从库。典型的数据库架构通常包含一个主写节点和多个读从库。越是能避免从写节点读取数据越好——毕竟写节点已经忙于处理所有写入操作。唯一例外是当你无法容忍任何复制延迟时(因为读从库始终至少比写节点延迟数毫秒)。但在大多数情况下,通过简单技巧就能规避复制延迟问题:例如更新记录后需立即使用时,可以在内存中直接填充更新后的细节,而非在写入后立即重新读取。
请警惕查询峰值(尤其是写入查询和事务操作)。数据库一旦过载就会变慢,进而形成恶性循环。事务和写入操作最易导致数据库过载,因为每个查询都需要数据库执行大量工作。若设计可能产生巨大查询峰值的服务(例如批量导入API),请考虑实施查询限流措施。
快操作与慢操作
服务必须确保某些操作快速完成。当用户与系统交互时(例如调用 API 或访问网页),应在几百毫秒内获得响应。但服务同样需要处理耗时操作——有些任务天然需要较长时间(例如转换超大 PDF 文件为 HTML)。通用解决方案是:拆解出最核心的工作量立即响应用户,其余任务转入后台处理。以 PDF 转 HTML 为例,可以立即渲染首页面,将其余页面的转换任务加入后台作业队列。
什么是后台任务?有必要详细解释这个概念,因为”后台任务”是系统设计的核心基础构件。每家科技公司都会建立某种后台任务运行系统,其核心包含两个组件:基于 Redis 等实现的队列集合,以及从队列获取并执行项目的任务运行服务。通过将形如 {任务名称, 参数} 的项目放入队列,即可实现后台任务入列。还可以设定后台任务在特定时间执行(适用于定期清理或汇总统计)。对于耗时操作,后台任务应作为首选方案——因为这通常是经过充分验证的技术路径。
有时需要自建队列系统。例如若需安排一个月后执行的任务,就不应将其放入 Redis 队列——Redis 通常无法保证如此长时间的持久化(即便可以,你也可能需要以特殊方式查询这些远期任务,而这在 Redis 任务队列中难以实现)。这种情况下,我通常会创建数据库表来管理待处理操作,为每个参数设置字段并添加 scheduled_at 列。随后通过每日任务检查 scheduled_at <= 当天 的记录,并在任务完成后删除或标记为已完成。
缓存
有时,一项操作会运行缓慢,是因为它需要执行一项耗时(即速度较慢)但对所有用户来说都相同的任务。例如,在一个计费服务中,如果要计算向用户收取的费用,可能需要调用一个 API 来查找当前的价格。如果按照每次使用收费(就像 OpenAI 针对每个令牌收费那样),这可能会(a)非常慢,(b)导致为提供价格信息的服务产生大量流量。这里常见的解决方案是使用缓存:每隔五分钟只查找一次价格,并在此期间将其存储起来。将缓存存储在内存中是最简单的方法,但使用一些快速的外部键值存储(如 Redis 或 Memcached)也很流行(因为这意味着你可以在一个应用程序服务器群中共享一个缓存)。
典型的现象是:初级工程师接触缓存技术后总想缓存所有数据,而资深工程师则力求最小化缓存。为何如此?这回归到我最初关于状态危险性的观点。缓存本身就是状态源——可能存入异常数据、与真实数据不同步、因提供陈旧数据引发诡异故障等等。在未全力优化原始性能前,绝对不应轻易使用缓存。例如:昂贵 SQL 查询缺乏数据库索引支持,此时选择缓存而非添加索引,无疑是本末倒置。
我经常使用缓存技术。工具箱里有个实用的缓存技巧:利用计划任务配合 S3 或 Azure Blob Storage 等文档存储系统构建大规模持久化缓存。当需要缓存超高开销的操作结果时(例如大客户周度使用报告),Redis 或 Memcached 可能无法容纳数据量。此时可将带时间戳的结果数据块存入文档存储,并直接从此提供文件服务。正如前文提到的基于数据库的长期队列方案,这是运用缓存思想而非特定缓存技术的典型范例。
事件
除缓存基础设施和后台任务系统外,科技公司通常还会建立事件中心。最常见的实现是 Kafka。事件本质上是队列——类似于后台任务队列——但队列中存储的不是”执行带参数的任务”,而是”发生了某事件”。典型场景如:每创建新账户时触发”账户已创建”事件,由多个服务消费该事件并执行相应操作:”发送欢迎邮件”服务、”扫描滥用行为”服务、”配置账户专属基础设施”服务等。
你不应过度使用事件机制。大多数情况下,最好让一个服务直接向另一个服务发起 API 请求:这样所有的日志都在同一个地方,更易于理解,而且您还能立即看到另一个服务的响应结果。事件机制适用于以下情况:发送事件的代码不一定关心事件的使用者如何处理该事件,或者当事件的处理量很大且不特别需要时间敏感性时(例如,对每条新的推文进行滥用扫描)。
推送和拉取
当数据需要从单一源头流向多个终端时,存在两种方案。最简单的是拉取模式——这也是大多数网站的工作原理:由服务器持有数据,用户通过浏览器发起请求拉取数据。但这种模式存在明显问题:用户可能重复拉取相同数据(例如反复刷新邮箱收件箱检查新邮件,这将导致整个网页应用重新加载,而不仅是邮件数据更新)。
另一种方式是进行推送。与让用户主动请求数据不同,你可以让它们注册成为客户端,然后当数据发生变化时,服务器会将数据推送给每个客户端。这就是谷歌邮箱的工作方式:你无需刷新页面就能获取新邮件,因为邮件会自动在到达时显示出来。
如果讨论对象是后台服务而非浏览器用户,就能明显看出推送模式的优势。即使在超大规模系统中,可能仅有百来个服务需要相同数据。对于变更不频繁的数据,在数据更新时向百个服务发起 HTTP 请求(或 RPC 等),远比每秒处理上千次相同数据的请求更为高效。
假设你需要向一百万个客户端(比如像谷歌邮箱那样)提供最新数据。这些客户端应该是主动推送数据还是被动接收数据呢?这要视具体情况而定。无论哪种方式,你都无法将所有数据都从一个服务器上运行出来,所以你得将这部分工作分配给系统的其他组件。如果是推送方式,那很可能意味着将每次推送都放在一个事件队列中,然后有一大群事件处理器从队列中获取推送内容并发送出去。如果是接收方式,那就意味着要启动一批(比如一百个)快速的读取复制缓存服务器,它们将位于主应用程序的前面,处理所有的读取流量。
核心路径
设计系统时,用户交互与数据流转存在多种可能路径,容易令人无所适从。关键在于聚焦”核心路径”:系统中至关重要且处理数据量最大的部分。以计量计费系统为例,核心路径可能包含客户扣费决策模块,以及需要对接平台所有用户行为以确定计费额的组件。
核心路径之所以重要,是因为其解决方案的选择空间远小于其他设计领域。构建计费设置页面的方法成千上万且大多可行,但能合理处理用户行为数据洪流的方案可能寥寥无几。核心路径的故障后果也更为严重——需要严重失误才能让设置页面导致整个产品崩溃,而触发所有用户行为的代码稍有不慎就会引发大规模故障。
日志与监控体系
如何识别系统问题?我从最谨慎的同事那里学到的重要经验是:在异常路径上实施激进日志记录。若编写函数检查多项条件以决定是否对用户端点返回 422,就应该记录触发的具体条件;若编写计费代码,就应记录每个决策过程(例如”因 X 原因未对此事件计费”)。许多工程师不愿这样做——因为会增加大量日志代码,破坏代码的优雅性——但即便如此仍应坚持。当重要客户投诉收到 422 错误时,你会庆幸这样做过:即使确实是客户操作错误,你仍需为其找出具体问题所在。
同时需要建立对系统运行部件的基础可观测性。这包括主机或容器的 CPU/内存使用率、队列规模、单请求或单任务平均耗时等指标。对于面向用户的指标(如请求耗时),还需监控 p95 和 p99 值(即最慢请求的延迟情况)。即使仅有个别超慢请求也值得警惕——因为这些往往来自最重要的大客户。若只关注平均值,很容易忽略部分用户正遭遇服务不可用的问题。
熔断机制、重试策略与优雅降级
关于熔断机制我曾撰专文论述,在此不再赘述。其核心要义是:必须审慎规划系统严重故障时的应对策略。
重试机制并非万能灵药。必须确保不会因盲目重试失败请求而加剧其他服务的负载。理想情况下,应将高频API调用置于”熔断器”中:若连续收到过多 5xx 响应,就暂停请求以让服务恢复。同时需警惕对写入操作进行重试——例如当发送”用户计费”请求后收到 5xx 响应时,你无法确定计费是否已执行。经典解决方案是使用”幂等键”:在请求中携带特殊 UUID,对方服务借此避免重复执行旧请求——每次执行操作后保存该幂等键,若收到相同键值的请求则静默忽略。
确定系统局部故障时的应对策略至关重要。以速率限制代码为例:当检查 Redis 令牌桶判断用户是否超出当前窗口请求限额时,若 Redis 不可用该如何处理?此时有两种选择:故障开放(允许请求通过)或故障关闭(返回 429 状态码阻断请求)。
选择故障开放还是故障关闭需根据具体功能而定。我认为速率限制系统几乎总应采用故障开放——这意味着限流代码出现问题不一定会造成重大用户事故。然而认证系统显然应该始终故障关闭:宁可拒绝用户访问自身数据,也绝不能让其获取他人数据。许多场景下正确行为并不明确,往往需要艰难权衡。
最后的思考
我刻意省略了部分主题。例如:何时将单体架构拆分为微服务、容器与虚拟机的选用时机、分布式追踪、API 设计规范。部分原因是认为这些并非关键(根据经验,单体架构完全可行),或觉得过于显而易见(理应使用追踪技术),抑或是篇幅所限(API 设计本身极为复杂)。
我想阐述的核心观点正如开篇所言:优秀的系统设计不在于炫技,而在于懂得在合适的位置使用经过充分验证的稳定组件。虽非管道工,但我想优秀的管道工程也是同理:若施工过程过于”精彩”,最终很可能落得狼狈不堪的下场。
尤其在大型科技公司——这些组件往往现成可用(即公司已具备事件总线、缓存服务等基础设施)——优秀系统设计反而显得平淡无奇。值得在技术会议上夸夸其谈的系统设计领域实在少之又少。当然例外确实存在:我曾见证自定义数据结构实现原本不可能的功能。但十年职业生涯中仅遇一两次,而平淡无奇的系统设计却日日可见。
译自:Everything I know about good system design (sean goedecke)