Skip to content
网游世界
网游世界

吾生有涯,而知无涯。

  • 首页
  • PHP
    • ThinkPHP
    • FastAdmin
    • webman
  • JavaScript
    • jQuery
    • AdminLTE
  • Free Pascal
  • Java
    • JeeSite
    • 若依
    • ruoyi-vue-pro
  • 其它
    • 操作系统
    • 树莓派
    • 前端
    • Null
  • 关于
网游世界

吾生有涯,而知无涯。

我所知的良好的系统设计(sean goedecke)

3Vshej, 2025年8月27日 周三2025年8月27日 周三

我看到很多糟糕的系统设计建议。较典型是:为领英优化过“你肯定没听说过队列”风格的文章,大概是针对刚入行的人。另一种则是为推特优化过的“如果你在数据库里存储布尔值,那你就是个糟糕的工程师”这类小把戏。即便是好的系统设计建议也可能存在不足。我很喜欢《数据密集型应用系统设计》,但我觉得它对于大多数工程师会遇到的系统设计问题来说,并不是特别有用。

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

这篇文章我以浅显的方式,写下关于优秀系统设计的一切。很多具体的判断确实需要经验,这是我无法在文章中传达的。但我会尽力写下我能写的内容。

认可优秀的设计

优秀的系统设计是什么样的?我之前写过,它看起来平淡无奇。实践中,它体现为系统长年稳定运行。当你产生”咦,这居然比预期简单得多”、”系统这个部分完全无需操心”的念头时,便意味着你正身处优秀的设计之中。不可思议的是,优秀的设计往往谦逊低调:糟糕的设计反而更令人印象深刻。对那些看似炫酷的系统,我始终抱有戒心。若某个系统同时包含分布式共识机制、多种事件驱动通信模式、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)

相关文章:

  1. gitlib 无法重置到上游代码 起因是派生了某个代码库,期间修改了些代码并发起了合并请求。...
  2. Windows 下的 rsync rsync 工具是很方便的文件同步工具,在 Windows 下可以使用 cwrsync。...
  3. 再说 Let’s Encrypt Let’s Encrypt 是一家免费、开放、自动化的证书颁发机构,由非营利组织互联网安......
  4. ICP 备案短信核验失败 遇到一个奇葩的事,网站要注销备案,在进行到短信核验时,无法进行。...
Null 操作系统

文章导航

Previous post
Next post

近期文章

  • UpdatePack7R2 更新包用于 Windows 7 SP1 和 Server 2008 R2 SP1
  • IPv4、IPv6 最大字符长度
  • SonarQube Windows 安装
  • Fail2ban 日志占用太多磁盘空间
  • Nginx 中使用 http_addition 在页面中附加内容

归档

  • 2025 年 10 月
  • 2025 年 8 月
  • 2025 年 7 月
  • 2025 年 6 月
  • 2025 年 5 月
  • 2025 年 4 月
  • 2025 年 3 月
  • 2025 年 2 月
  • 2025 年 1 月
  • 2024 年 12 月
  • 2024 年 11 月
  • 2024 年 10 月
  • 2024 年 9 月
  • 2024 年 8 月
  • 2024 年 7 月
  • 2024 年 6 月
  • 2024 年 5 月
  • 2024 年 4 月
  • 2024 年 3 月
  • 2024 年 2 月
  • 2024 年 1 月
  • 2023 年 12 月
除非特殊说明,本站作品采用知识共享署名 4.0 国际许可协议进行许可。
豫公网安备 41010402002622号 豫ICP备2020029609号-3
©2025 3Vshej