什么是好的系统设计?我的系统设计心得

我看到很多关于系统设计的糟糕建议。其中一个经典例子是针对LinkedIn优化的“你可能从未听说过_队列_”类型的帖子,显然是针对刚入行的人。另一个是针对Twitter优化的“如果你在数据库中存储布尔值,那你就是个糟糕的工程师”的巧妙技巧1。即使是好的系统设计建议也可能有些问题。我喜欢《设计数据密集型应用程序》,但我不认为它对工程师在系统设计中遇到的绝大多数问题特别有用。

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

这篇文章是我试图以宏观视角记录下我对良好系统设计的所有理解。许多具体的判断确实需要经验,而这些经验我无法在这篇文章中传达。但我正在尝试写下我能写下的内容。

元素周期表

识别良好的设计

良好的系统设计是什么样子的?我之前写过,它看起来并不令人印象深刻。在实际应用中,它表现为长时间内系统运行稳定无误。如果你有这样的想法:“嗯,这比我预期的要简单”,或者“我从未需要考虑系统这一部分,它运行良好”,那么你就可以判断这是良好的设计。矛盾的是,良好的设计往往不显山露水:糟糕的设计常常比良好的设计更令人印象深刻。我总是对看起来很复杂的系统持怀疑态度。如果一个系统采用了分布式共识机制、多种事件驱动通信方式、CQRS(命令查询责任分离)和其他巧妙的技术,我会怀疑是否存在某种根本性的错误决策需要通过这些技术来弥补(或者系统只是单纯地过度设计了)。

我经常是唯一持这种观点的人。工程师看到复杂系统中的诸多有趣组件时会感叹:“哇,这里蕴含了大量系统设计!”事实上,复杂系统通常反映了良好设计的缺失。我之所以说“通常”,是因为有时确实需要复杂系统。我参与过许多通过合理设计赢得复杂性的系统。然而,一个能正常运行的复杂系统总是从一个能正常运行的简单系统演变而来。从零开始设计复杂系统是一个非常糟糕的主意。

状态与无状态

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

你应该尽量减少系统中有状态组件的数量。(从某种意义上说,这是显而易见的,因为你应该尽量减少系统中所有组件的数量,但有状态组件特别危险。) 你应该这样做的原因是,有状态组件可能会进入不良状态。我们的无状态 PDF 渲染服务可以安全地永远运行,只要你做一些大致合理的事情:例如,在可重启的容器中运行它,这样如果出现任何问题,它可以被自动终止并恢复到正常工作状态。有状态服务无法像这样自动修复。如果你的数据库中出现错误条目(例如,格式导致应用程序崩溃的条目),你必须手动进入并修复它。如果数据库空间不足,你必须想办法删除不需要的数据或扩展它。

这在实际中意味着有一个服务负责管理状态(即与数据库通信),而其他服务则执行无状态操作。避免让五个不同服务都写入同一张表。相反,让其中四个服务通过API请求(或触发事件)调用第一个服务,并将写入逻辑集中在该服务中。如果可能,读取逻辑也值得这样做,尽管我对此不太绝对。有时,服务快速读取user_sessions表比向内部会话服务发起2倍慢的HTTP请求更好。

数据库

由于状态管理是系统设计中最重要的一部分,因此状态存储的位置通常是最关键的组件:数据库。我大部分时间都在使用 SQL 数据库(MySQL 和 PostgreSQL),因此我将重点讨论这些数据库。

模式与索引

如果你需要将某些数据存储在数据库中,首先要做的是定义一个包含所需数据结构的表。模式设计应具备灵活性,因为一旦数据库中包含数千或数百万条记录,更改模式将变得极为繁琐。然而,如果设计过于灵活(例如将所有数据都存放在一个“value” JSON 列中,或使用“keys”和“values”表来跟踪任意数据),这将为应用程序代码引入大量复杂性(并可能带来一些非常棘手的性能限制)。在此划定界限是一个判断问题,具体情况具体分析,但总体而言,我力求让表结构易于人类阅读:你应该能够通过查看数据库模式,大致了解应用程序存储了什么数据以及存储的原因。

如果你预计表的行数会超过几行,就应该为其添加索引。尽量让索引与最常见的查询条件匹配(例如,如果你按emailtype进行查询,就创建包含这两个字段的索引)。索引的工作原理类似于嵌套字典,因此请确保将高基数字段放在前面(否则每次索引查找都必须扫描所有type为特定值的用户,才能找到对应的email)。不要对你能想到的每一个字段都创建索引,因为每个索引都会增加写入开销。

瓶颈

在高流量应用中,访问数据库通常是性能瓶颈。即使计算部分相对低效(例如在预分叉服务器如Unicorn上运行的Ruby on Rails),这一情况依然成立。这是因为复杂应用程序需要进行大量数据库调用——每个请求可能需要数百次调用,且往往是顺序执行的(因为你无法在确认用户是否属于某个组织之前,先判断其是否存在滥用行为,等等)。如何避免出现瓶颈?

在查询数据库时,直接查询数据库。几乎在所有情况下,让数据库自行处理比手动操作更高效。例如,如果你需要从多个表中获取数据,使用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 按令牌收费),这可能(a)速度过慢且(b)会给提供价格的服务带来大量流量。经典解决方案是缓存:每五分钟查询一次价格,并在中间存储值。最简单的缓存方式是内存缓存,但使用一些快速的外部键值存储(如Redis或Memcached)也非常流行(因为这意味着你可以将一个缓存共享给多个应用服务器)。

典型的模式是,初级工程师在了解缓存后会想缓存“一切”,而高级工程师则希望尽可能少地使用缓存。为什么会这样?这归结于我之前提到的关于状态性的危险。缓存是一种状态来源。它可能包含异常数据,与实际数据不一致,或因提供过期数据而引发神秘故障,等等。你绝不应在未先认真尝试优化性能的情况下进行缓存。例如,缓存一个未被数据库索引覆盖的昂贵SQL查询是毫无意义的。你应该直接添加数据库索引!

我经常使用缓存。工具箱中一个有用的缓存技巧是使用定时任务和文档存储(如 S3 或 Azure Blob Storage)作为大规模持久化缓存。如果你需要缓存某个非常耗时的操作结果(例如为大型客户生成的每周使用报告),可能无法将结果放入 Redis 或 Memcached 中。相反,你可以将带时间戳的结果存储在文档存储中,并直接从那里提供文件。就像我上面提到的基于数据库的长期队列一样,这是使用缓存“理念”而无需特定缓存技术的一个示例。

事件

除了某种缓存基础设施和后台任务系统外,科技公司通常还会拥有一个“事件中心”。最常见的实现方式是Kafka。事件中心本质上就是一个队列——与后台任务队列类似——但队列中放置的不是“执行此任务并使用这些参数”,而是“发生了这件事”。一个经典示例是为每个新创建的账户触发“新账户创建”事件,然后让多个服务消费该事件并采取相应行动:例如“发送欢迎邮件”服务、“检测滥用行为”服务、“配置账户级基础设施”服务等。

你不应该过度使用事件。大多数情况下,让一个服务通过API请求调用另一个服务更好:所有日志都在同一个地方,更容易理解,而且你可以立即看到另一个服务返回了什么。事件适用于发送事件的代码并不关心消费者如何处理事件的情况,或者事件是高吞吐量且不特别注重时效性(例如对每个新推文进行滥用扫描)。

推送与拉取

当需要将数据从一个地方传输到多个其他地方时,有两种选择。最简单的方法是“拉取”。这是大多数网站的工作原理:有一个服务器存储着一些数据,当用户需要这些数据时,他们会通过浏览器向服务器发送请求,以拉取这些数据到本地。问题在于,用户可能频繁拉取相同数据——例如刷新邮箱收件箱查看新邮件时,这会导致整个网页应用被重新加载,而非仅更新邮件相关数据。

另一种方案是_推送_。不再允许用户主动请求数据,而是允许他们注册为客户端,当数据发生变化时,服务器会主动将数据推送给每个客户端。这就是 GMail 的工作原理:你无需刷新页面即可获取新邮件,因为新邮件会自动显示在页面上。

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

假设你确实需要向一百万个客户端提供实时数据(就像Gmail那样)。这些客户端应该采用推送还是拉取模式?这取决于具体情况。无论哪种方式,你都无法仅靠单台服务器运行整个系统,因此需要将任务分发给系统中的其他组件。如果采用推送模式,这通常意味着将每个推送操作放入事件队列,然后由大量事件处理器从队列中拉取并发送推送数据。如果你采用拉取模式,这意味着需要部署大量(例如一百台)快速4读取副本缓存服务器,这些服务器将位于主应用程序之前,处理所有读取流量5

热点路径

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

热点路径之所以重要,是因为它们的解决方案数量比其他设计领域更少。你可以用千百种方式构建计费设置页面,它们大多都能正常工作。但合理处理用户操作数据流的方式可能只有寥寥几种。关键路径也更容易出现严重问题。你必须彻底搞砸一个设置页面才能让整个产品崩溃,但任何触发所有用户操作的代码都可能引发重大问题。

日志记录与指标

如何判断是否存在问题?我从最谨慎的同事那里学到的一点是,在异常路径中要积极记录日志。如果你在编写一个函数,该函数会检查一系列条件以确定用户界面端点是否应返回422错误,那么你应该记录触发的具体条件。如果你在编写计费代码,你应该记录每个决策(例如“我们不为这个事件计费,因为X”)。许多工程师不这样做,因为这会增加大量日志模板,并使编写优雅的代码变得困难,但你还是应该这样做。当重要客户投诉收到 422 响应时,你会庆幸自己做了这些记录——即使客户确实犯了错误,你也需要帮助他们弄清楚“他们具体犯了什么错误”。

你还应具备对系统运行部分的基本可观察性。这包括主机或容器的 CPU/内存使用情况、队列大小、平均请求处理时间或任务处理时间等指标。对于面向用户的指标,如每次请求的时间,你还需要关注p95和p99(即最慢请求的速度)。即使只有一两个非常慢的请求也令人担忧,因为它们往往来自你最大且最重要的用户。如果你只关注平均值,很容易忽视部分用户认为你的服务无法使用的事实。

杀死开关、重试和优雅失败

我曾撰写过一篇关于杀死开关的完整文章,这里不再赘述,但核心要点是:您应仔细考虑系统发生严重故障时应如何处理。

重试并非万能良方。您需要确保盲目重试失败请求不会给其他服务带来额外负载。如果可能,将高并发API调用放入“断路器”机制中:若连续收到过多5xx响应,则暂停发送请求一段时间,以便服务恢复。你还需要确保不要重试那些可能成功也可能失败的写入事件(例如,如果你发送一个“为该用户开具账单”请求并收到5xx响应,_你无法确定_该用户是否已被开具账单)。解决此问题的经典方案是使用“幂等性键”,即请求中包含的特殊UUID,其他服务可通过该键避免重复执行旧请求:每次执行操作时,它们会保存幂等性键,若收到带有相同键的请求,则静默忽略。

还需明确系统部分故障时的处理方式。例如,假设你有一段速率限制代码,通过检查 Redis 桶来判断用户在当前时间窗口内是否请求过多。当该 Redis 桶不可用时该如何处理?你有两种选择:以“开放失败”方式让请求通过,或以“关闭失败”方式返回 429 错误并阻塞请求。

是否应采用失败打开或关闭取决于具体功能。在我看来,速率限制系统几乎总是应采用失败打开。这意味着速率限制代码的问题并不一定导致重大用户可见事件。然而,身份验证(显然)应始终采用失败关闭:拒绝用户访问其自身数据比允许用户访问其他用户数据更安全。有很多情况下,正确的行为并不明确。这往往是一个艰难的权衡。

最后思考

有一些话题我故意没有在这里讨论。例如,是否或何时将单体应用拆分为不同服务、何时使用容器或虚拟机、追踪、良好的 API 设计等。部分原因是我认为这些并不重要(根据我的经验,单体应用完全可行),或因为我认为这些显而易见无需赘述(你应该使用追踪),或因为我没有时间深入探讨(API 设计较为复杂)。

我试图传达的核心观点就是本文开头提到的:良好的系统设计并非依赖于花哨的技巧,而是知道如何在合适的位置使用经过充分验证的、看似枯燥的组件。我不是管道工,但我想良好的管道系统也是类似的:如果你做的事情太过花哨,很可能最终会弄得一团糟。

尤其是在大型科技公司,这些组件已经现成可用(即公司已经拥有某种事件总线、缓存服务等),良好的系统设计看起来会非常普通。几乎没有多少领域需要进行那种可以在会议上讨论的系统设计。它们确实存在!我见过自定义数据结构使某些功能成为可能,否则这些功能将无法实现。但过去十年里,我只见过一两次这样的情况。我每天看到的都是枯燥的系统设计。


  1. 你应该存储时间戳,并将时间戳的存在视为true。我有时会这样做,但并非总是如此——在我看来,保持数据库模式的可读性是有价值的。
  2. 严格来说,任何服务都会以某种形式存储信息,至少在内存中存储一段时间。这里通常指的是在请求-响应生命周期之外存储信息(例如,持久化存储在磁盘上的某个位置,如数据库中)。如果你可以通过简单地启动应用服务器来部署应用的新版本,那么这就是一个无状态应用。
  3. 游戏开发者在 Twitter 上会说,任何超过 10 毫秒的响应时间都是不可接受的。无论这种说法是否合理,事实是成功的科技产品并非如此——如果应用程序正在做对用户有用的事情,用户会接受较慢的响应。
  4. 它们之所以快速,是因为它们无需像主服务器那样与数据库进行通信。理论上,这可以只是一个静态文件,当被请求时从磁盘中读取,甚至可以是内存中存储的数据。
  5. 值得一提的是,这些缓存服务器要么定期轮询主服务器(即拉取),要么由主服务器主动推送新数据给它们(即推送)。我认为选择哪种方式并不重要。推送方式能提供更及时的数据,但拉取方式更为简单。

本文文字及图片出自 Everything I know about good system design

共有 383 条讨论

  1. > 我经常独自一人面对这个问题。工程师们看到复杂系统中众多有趣的组成部分时,会感叹:“哇,这里有很多系统设计在进行!”事实上,复杂系统通常反映了良好设计缺失的现实。

    对于求职者来说,在面试中忘记这一点非常重要。

    过去,我在系统设计面试中犯过试图传达这一点的错误。

    某个假设的初创应用

    > 面试官:“那关于背压呢?”

    > “对于这个QPS量级,这其实不值得考虑。”

    > 面试官:“为什么不使用队列而不是定时任务?”

    > “我认为这对这个应用来说没有必要,但这里是权衡点。”

    > 面试官:“如何在SQL和NoSQL数据库之间做出选择?”

    > “差别不大。选择团队最擅长的即可。”

    这些并非他们想要的答案。你需要在白板上画满方框和箭头,直到看起来像是用Kubernetes管理Kubernetes。

    1. (作为背景,我已经进行了数百次系统设计面试,并在公司培训了十几个人如何进行这类面试。其他面试官可能有不同的做法或关注点,但我认为我这里说的内容与常规做法相差不大。)

      我对你所说的有三点看法:

      1. 你给出的答案没有提供太多信息(队列问题是个例外)。隐含的问题不仅仅是你要选择什么,而是为什么选择它。哪些因素会促使你做出特定的决定?你在提供答案时在考虑什么?你并没有真正表达出你的考虑。

      一位优秀的面试官会通过追问来获取他们做出决策所需的信息。所以如果你说“背压在这里不值得担心”,他们会问你“什么时候值得担心”,以及“在这种情况下你会怎么做”。但并非所有面试官都是优秀的,有时他们会直接说“我无法从候选人那里获得太多信息”,而“没有肯定回答”就等于“否定回答”。作为求职者,你希望让面试官的工作变得轻松,而不是困难。

      1. 即使面试官很出色,并成功从你那里获取了信息,他们可能会写下类似“候选人能够合理解释为何选择特定技术,但获取这些信息需要大量追问和挖掘——沟通能力存在不足”的评价。作为求职者,你应该主动提供面试官所需的所有信息,而不是勉强或不情愿地提供。(这一点在非面试情境下同样适用。)

      3. 我基本上不同意那个关于SQL/NoSQL的回答。团队专业知识是一个因素,但这些技术存在显著差异;根据你需要完成的任务,其中一种技术可能在特定场景下远优于另一种。你的回答会因表明你缺乏足够的场景经验来识别这一点而受到批评。

      1. +1 赞同这个观点。优秀的候选人无需你进一步追问背压问题,他们会主动解释为何当前QPS下无需考虑背压、何种QPS水平下开始需要考虑,以及若服务规模扩大后如何将其融入设计。

        我常告诉准备系统设计面试的人:职位越高,越需要主动掌控面试节奏,知道何时深入探讨、探讨什么内容,以及如何向面试官传递最有价值的信息。

        1. 作为应试者,我会利用此类问题展示对主题的掌握程度。对于这个问题,我会指出,采用线程-CPU模型并结合异步I/O设计,通过最大化活跃连接数、智能连接池接受与驱逐策略以及有限缓冲区,这些设计本身就能内在限制超额订阅。但我仍会详细讨论健康监测和外部断路器机制,以及在协议层面使用429状态码或其他机制(如可用)进行流量控制以表达背压。然后我会利用这一点来强调“每个客户端一个线程”设计方案的弊端,以及为什么会出现这种情况,以及各种替代方案。

          让面试官告诉你他们已经听够了,然后换个话题。

          1. 我恳求你写一篇文章来扩展这些观点。我愿意付钱阅读这篇文章。

            1. 真的!

              我在这里的评论中已经写过这个了…

              以下是简要总结:

              – 典型的客户端每线程编程方式极其低效,因为它需要大量可动态扩展的栈空间(在合理范围内), 这导致程序员将客户端状态分散到栈的各个部分,从而使得客户端状态的内存和缓存占用量巨大,尽管这些状态本身具有高度可压缩性,而这正是导致在实现C10K(服务10,000个客户端)时效率降低的原因,这也是90年代出现C10K技术的原因

              – 你可以通过使用延续传递风格(CPS)异步 I/O 编程来高度压缩所述的每个客户端程序状态,无论是手动编码还是使用支持异步函数的现代语言中的异步函数——这种方法倾向于激励程序员将程序状态压缩成比执行栈小得多的结构,从而大大减少程序的每个客户端内存和缓存占用

              需注意,减少程序的内存和缓存占用同时会降低其内存带宽占用,提升缓存局部性并降低延迟。这意味着可在相同硬件上服务更多客户端。这就是C10K技术的核心要义。

              其他技术如纤维和绿色线程则位于从手动编写的CPS异步I/O编程到按客户端分配线程的顺序编程的连续光谱上。你可以选择代码的效率水平。

              当应用C10K技术时,你可以实现每个CPU一个线程——看,妈妈!没有上下文切换——这自然会提升延迟和效率。但每CPU一个线程还有另一个好处:它使管理服务整体健康状况变得更容易,至少在每CPU层面是这样,因为你可以管理所有连接的客户端,因此可以设置连接的驱逐策略和接入策略。特别是你可以设置最大连接数,这意味着你可以设置仅略微超过硬件能力的上限。

              否则 [即使你采用了每个 CPU 一个线程的方案,尽管在这种情况下使用断路器的重要性会降低] 你必须有一种方式来衡量服务的健康状况,并需要对其进行监控,同时你的监控工具需要能够通过告知服务开始拒绝新任务来“断开电路”。这就是HTTP状态码429等机制发挥作用的地方——它只是向客户端表达背压的一种方式,尽管如果你有可用且可执行的流量控制机制,它也能起到同样的作用。对于基于线程的CPU服务,你仍然需要能够监控负载、延迟和吞吐量,以便你知道何时需要添加硬件。当然,你希望构建尽可能水平扩展的服务,以便添加硬件变得容易,尽管你也需要能够识别并停止异常客户端(处理DoS和DDoS几乎总是需要服务外部的组件)。

              确保所有中间件都能适当地响应背压,包括拥有自己的断路器,这样你将拥有一个相当健壮的系统——在极端压力下,它能够卸载负载并继续以某种程度运行。

              你需要能够表达客户端优先级(以便你能继续处理部分负载)和配额(以便病态的高优先级客户端不会对你进行DoS攻击)。

              当然,还有更多需要说明的地方。

              顺便说一下,我今天检查了一下,大语言模型(LLMs)似乎知道所有这些内容,而且它们还能为你指出框架、博客和许多其他文档。也就是说,如果你不提示它们告诉你关于每个 CPU 的线程内容,它们就不会告诉你。

              请记住,C10K技术在开发人员时间上是昂贵的,尤其是对初级开发人员而言。

              1. 这背后还有系统级别的理由。如果没有良好的隔离,你会陷入反馈循环:线程开始互相干扰。这导致响应时间变慢。在给定的请求压力下,这会导致更多并行线程。这进一步减慢了它们的速度。如果压力出现短暂峰值,导致响应时间降至临界点以下,这样的系统将无法恢复,服务器会无故疯狂计算,直到重启后才恢复正常。

                1. 是的,因此需要断路器。通过将提供的容量设置为实际容量的某个倍数,你可以将过多需求的影响限制为自然产生背压(拒绝请求)而非超时和重试。这使得你能够访问某些端点——例如健康和诊断端点,因为CPU使用率不会高到无法运行这些端点。

                2. 大多数系统会限制线程池的大小,并将过多的请求放入队列中。

              2. 对理论的良好总结,但奇怪的是,每次我重写代码以使用异步时,整体性能都会下降约10%……这正是我估计的由编译器生成的异步状态机制引入的开销。

                我尚未看到现代语言中令人信服的A/B对比测试结果。我的实际经验与传统观点并不一致!

                1. 这可能是因为你仍在将状态散布在栈上?使用异步函数时可以这样做,因此你仍然拥有栈/纤维/线程,因此并未获得太多收益。

                  采用CPS(计算流式编程)方法时,你确实不会有多个栈。

                  哦,相关地,函数式核心、命令式外壳(FCIS)概念在此适用。命令式外壳是异步I/O事件循环/执行器。其他部分是可能请求I/O的函数式状态转换,如果你将I/O请求表示为由执行器执行的返回值,那么这些状态转换就可以是函数式的。函数式状态转换可以使用任意多的栈空间,但完成后栈空间会消失——状态转换之间不共享栈空间。

                  当然,你不想让状态转换拥有无限的 CPU 时间,但对于某些应用程序,可能不得不允许这种情况,此时就会遇到问题(天啊,线程取消真是个麻烦!)。

                  FCIS 的目的是让测试状态转换变得简单,因为除了一个输入、一个世界状态外,无需模拟其他内容,只需将输出与预期结果进行比较。 “命令式外壳”也可以通过一个非常简单的 “应用程序” 和设置来测试,以证明其正常工作,而无需通过复杂的模拟设置来测试整个系统。

      2. 你描述的正是面试流程与实际需求脱节的情况,以及如何通过“玩转游戏”来获得入职机会。

        但另一方面,这种面试流程本身也可能成为候选人避免“玩游戏”的信号,因为大多数(当然不是全部)公司在面试过程中探测错误信号的行为,往往反映了其整体运作方式。

        (我曾身处这两种类型的公司,也经历过面试双方的角色)

      3. 所有这些“信号”的胡说八道都可以被大语言模型(LLM)和阅读过“如何通过系统面试”的人所模仿。是的,很棒的“信号”。

        1. 其实并非如此,在现场口头面试中并非如此。

          不过我曾遇到过这样一个案例:我们以为要聘用的人和实际聘用的人是不同的人。解决办法是始终进行一次最终的面对面面试。

      4. 原帖指出,系统设计面试的存在是为了表演性地回答一系列与实际成为优秀系统设计师无关的问题。在你的回应中,你声称他们提出的诚实答案没有提供太多“信号”,并且你更希望候选人能够参与到这个过程的表演性中。这是正确的——但这并不是对原帖主张的反驳,原帖的主张是:在面试中背诵一套关于系统设计的事实 ≠ 成为一名优秀的系统设计师。

    2. 这又回到了“面试是双向的”这一观点。你给出的所有答案都非常合理,如果我是你的面试官,我会给你一个高分。另一方面,如果你在一家公司面试,他们不会因为这些回答给你高分,那其实更多地反映了他们的问题,而不是你的问题,可能不是一个理想的工作场所。

      但回到你的观点,很多时候求职者在面试时并没有被拒绝的余地,他们需要尽快找到工作以维持生计。因此,尽管面试是双向的,但仍需进行一定程度的调整,以确保自己能站在对方的那一边。

      1. 如果我是你的面试官,我会:非常尊重你的回答,无法在我的评分表上打勾,试图在反馈中解释这一点,被告知我们必须遵循评分表以避免偏见,然后看着他们因为有人决定玩建筑积木游戏而放弃你。我甚至可能会考虑后来给你发邮件道歉,但最终不会这么做,因为我可能会因为暴露我们面临的法律风险而惹上麻烦,毕竟道歉可能被视为承认错误。

        1. 如果候选人没有提出澄清问题以理解QPS、存储要求和吞吐量考虑,那是个减分项。

          此时,如果你想让他们设计一个功能齐全的分布式系统,你应该阻止他们,告诉他们需要处理的流量类型,然后让他们继续。

          如果他们坚持设计一个无法处理指定负载的系统,他们很可能已经失败了面试。

          1. 问题在于,人们对单一系统能够处理的负载存在理解上的偏差。例如,我的这台8年机龄的四核i5台式机,通过一些批处理优化,可以处理每秒5位数的请求,P99延迟为15毫秒,同时处理一些涉及多个连接的复杂应用逻辑。我没有在现代迷你电脑上尝试过相同的基准测试,但预计结果应类似。这已远超大多数公司实际所需的处理能力。Visa宣称其全球处理能力可达约7万次每秒(tps)。

            上次面试时,我被问及如何设计一个系统来处理每分钟数万个事件,如果你稍微思考一下这个问题,就会意识到其中大部分并不需要实际处理。我回答说:“你不需要做任何特殊的事情。只需使用普通的PostgreSQL/MySQL即可在笔记本电脑上处理超过这个数量的请求。” 入职后我才得知,面试题中其实包含了关于队列(如Kafka)的预期答案。至今仍不清楚为何如此。

            1. 因为Web开发人员早已习惯糟糕的设计、优化不足的数据库 schema 以及网络存储延迟,以至于他们完全不知道单台服务器(甚至普通台式机)究竟能做到什么程度。

              比如当我告诉抱怨“查询速度慢”的团队,数据库实际上在亚毫秒级时间内执行这些查询时。我不知道你们栈的其他部分在做什么,但祝你好运去弄清楚——那不是我的问题。

            2. “证明你能将昨天的解决方案应用于今天的问题”是一个好策略,除非在那些今天与昨天呈指数级差异的行业。

          2. 我还需要你数据的美元价值和后果清单。我们将一起在Excel中度过分配的时间。

        2. 我面试过几十个人,虽然我很少问系统设计问题,我们的流程也远非勾选所有框,但你的评论仍然非常准确。尤其在后期阶段,政治因素开始介入。

        3. 没错,这只有在你对老板有足够影响力且愿意为招聘结果承担责任时才可行。

      2. 如果我是面试官,我会通过一些假设情境调整问题陈述,以考察他们的知识深度:

        > “这种QPS水平下,这其实不值得考虑”

        “如果迈克尔·杰克逊去世,你的(搜索|新闻|名人八卦)服务遭遇远超设计参数的流量激增,你会如何预判并缓解此类事件?”

        (如果答案并非仅仅是“反压”,而是开始讨论DDoS缓解、异常检测、缓存或从极常见的查询中返回静态结果、启动新容量以适应流量激增、黑洞流量以保护整体服务等,则加分)

        > 面试官:“为什么不使用队列而不是定时任务?”“我认为这对这个应用来说没有必要,但这里有权衡。”

        “如果有一部分客户需要比定时任务更快的响应速度,该怎么办?”

        (然后这可能演变成关于根据需求拆分流量的讨论,即是否值得添加拆分流量的逻辑,还是直接为所有人使用队列,或许对仅来自这些客户的请求直接进行API调用,而不使用队列或定时任务,依赖于这些请求数量不多或频率较低,从而用容量换取延迟等。)

        > 你如何在SQL和NoSQL数据库之间做出选择?

        我本以为候选人至少能讨论索引、在数据库中进行连接与在应用程序中进行连接的权衡、模式迁移和升级、在静态数据与动态数据之间建立分离等话题。如果他们无法做到这一点,而是轻描淡写地表示“只要团队觉得舒服就行”,那这就是他们知识体系中的一个漏洞。通常,我们会对高级候选人进行系统设计面试,因为他们将负责决定架构,即使不直接招聘团队,也会向负责招聘的高级经理提供意见,这样就可以像更换架构一样轻松地更换团队。

        1. 完全正确。我不希望有人设计复杂臃肿的系统,但我确实希望他们能够阐述权衡取舍,并解释为何各种组件可能有用。

        2. >我本以为候选人至少能讨论索引、数据库与应用程序中连接的权衡、模式迁移和升级、静态数据与动态数据的分离等。

          问题是,这些权衡大多只适用于旧数据库。更相关的维度是数据库的分布程度、复制类型等。

      3. > 这其实更多地反映了他们自身的问题,而非你的问题,可能并不是一个理想的工作场所。

        如果一位优秀的“技术”工程师排除掉所有面试环节存在问题的公司,他们很可能就会失业。

        你必须在一定程度上忽略糟糕的面试流程。

        > 你仍然需要进行一些调整,以确保你站在对方的那一边,可以说。

        完全正确。但如果他们试图用Leetcode来测试你,你必须决定自己是否还有一点自尊,还是你们只是在玩过家家。

    3. 这是糟糕的建议。简单而优雅的设计并不始于忽视潜在问题。

      这些问题都是为了引发讨论,而不是技术知识竞赛。这些回答并不展示智慧,而是暴露了缺乏成熟度。如果你拒绝接受面试,那不是面试官的错。

      1. 我同意,这些回答给人一种“你的问题很愚蠢,我太聪明了,不值得花时间回应”的感觉。如果你不想得到这份工作,那就别去面试!

    4. 没错,这正是LinkedIn驱动型开发存在的根本原因。在简历上列出上百种技术看起来比描述如何仅使用模块化单体架构和单个Postgres实例让一切正常运行更具吸引力。

    5. 除了在兄弟评论中提到的“双向沟通”观点外,我认为一个好的面试官会说:“这很好,我也会保持简单,但现在我正在测试你对$thing的了解。”如果一个人不停地谈论错误的事情,那当然是个坏兆头。

    6. 你_想_在这些地方工作吗?根据我的经验,如果他们在面试中要求你使用Kubernetes运行Kubernetes,那么他们的系统中也正是这样做的。

        1. 还有另一个原因。我内心深处渴望加入一个专注于真正数据密集型应用程序(正如Martin Kleppmann所称)的团队,那里的一切复杂性都有其合理性。

          例如,我更倾向于“你只需要Postgres”类型的软件工程师。但阅读那些关于Discord团队如何使用Cassandra和ScyllaDB处理1万亿条消息的炫酷博客文章,让我感到羡慕。

          此外,似乎要被这类雇主聘用,你需要证明自己已经具备相关经验,这形成了一种先有鸡还是先有蛋的困境。

          1. 我感觉“你只需要Postgres”这句话的(往往未言明的)后续是“直到你真正处理到1万亿条消息”。

            换句话说,你羡慕的开发者并非从Cassandra和ScyllaDB开始,而是从“消息过多”的问题开始。这不是架构选择,而是产品成功的结果。

            1. 完全正确。换句话说,不幸的是,并非每个人都有机会参与一个产品从“我们只需要Postgres”到“天啊,我们成功了,Cassandra是什么来着?”的有机演变。

              1. 作为一个数据点,我曾在两家数据密集型初创公司工作,最终它们都需要将部分表状数据从Postgres中提取出来,而这两家公司当时的估值都已超过$100MM。

                当然,这因领域而异,但非Postgres解决方案通常针对非常具体的问题而设计——除了个别情况外,它们在所有方面都比Postgres差。

          2. 只有那些赚取丰厚利润的公司才能承担过度设计的成本。

            过度设计在公司盈利越多时越普遍,而那些过度设计的公司会不惜重金维持这种设计。

            1. 我前任CTO和工程副总裁让我敬佩的一点是,他们仍具备足够的技术素养来指出这类问题。尽管那家公司规模庞大,但他们确实将复杂性和过度工程化控制在极低水平。

              遗憾的是,其他高管过分依赖人工智能提升生产力,导致情况变得一团糟。

              1. 许多公司试图通过大量招聘来实现规模化,但项目中人员越多,最终产生的过度工程化问题就越严重。

                其中一部分是管理大量个体贡献者的后果,我仍然认为许多公司使用微服务架构更多是为了扩展到更多团队,而非提升可扩展性、可靠性或可观察性。

                其中一部分是人们提出巧妙解决方案(并在事后离开),以及大量基于简历驱动的开发。

              2. 换句话说,他们相信的不是增加个人权力,而是其他原则

    7. > 这些不是他们想要的答案。

      这些正是我们想要的答案。作为系统设计面试(我做过数百次),我希望你们从这些答案开始,如果你们解决了问题且还有时间,我们可以逐步增加复杂性,进入深入探讨的阶段。

      看着中层工程师眼中逐渐升起的焦虑,当他们意识到并非所有问题都能通过缓存解决时,这场景也挺有趣的。“好,你已经缓存了,现在如何填充缓存而不遇到相同的性能问题?”

      1. > 我希望你从这些答案开始,如果问题已经解决且还有时间,我们可以逐步增加复杂性,进入深入探讨的阶段

        完全正确。面试的一部分是解释何时以及为何需要这些技术,以展示你的理解。

        如果候选人给出“我认为这不重要,因为你们是初创公司”或“我只会使用我熟悉的数据库”等非答案,这完全没有展示知识。这种回答方式会让面试官认为你缺乏相关知识,或者你没有认真对待他们的问题,没有花时间去思考。有一种候选人会申请初创公司,因为他们认为没什么大不了的,可以随随便便地做几年,然后再换下一份工作,这种候选人和那些过度工程化的候选人一样糟糕。

        面试是你展示你了解这些主题以及何时应用它们的机会,而不是争论初创公司不应该关心这些问题的时机。

        1. > 面试是你展示你了解这些主题以及何时应用它们的机会,而不是争论初创公司不应该关心这些问题的时机。

          我认为回答这类问题的良好方式是某种形式的“在我们讨论的规模下,我们可能不会遇到这些问题,但当遇到A、B、C问题时,我们可以尝试X、Y、Z解决方案。”

          这表明你正在做出有意识的权衡,并知道何时应用更复杂的解决方案。如果你能具体解释如何建立机制来识别A、B、C问题发生的时间,以及如何设计系统以便轻松添加X、Y、Z解决方案,那将加分不少。

          此外,如果你意识到垂直扩展如今能以相对较低的成本为企业争取大量时间,这将非常令人印象深刻。服务器现在可以配备128个CPU和64TB内存于单台机器上 🙂

          1. 没错,你可能在$year年规模较小,但显然你预期会增长,而他们不希望更换团队,因为他们无法想象在其他情况下如何运作。

        2. > 面试的一部分是解释何时以及为何这些技术是必要的,以展示你的理解。

          稍微修改后的“解释何时何地这些技术是*不*必要的”就没那么受欢迎了。

      2. > 我希望你从这些答案开始,然后如果问题解决了且还有时间,我们可以深入探讨复杂性。

        你会明确告诉别人这一点吗?如果会,那很好;如果不会,请开始这样做!我认为当今面试中最大的问题之一是期望不匹配,尤其是应聘者带着“他们希望看到的是你解决FAANG级别问题的经验如此丰富,以至于这已成为你的默认模式”的假设来参加面试。

        1. 我认为即使在FAANG类公司,也只有少数幸运儿参与过那样的规模项目。大多数开发者只是使用现有的基础设施和工具,而不会参与S3或BigTable的创建。

        2. > 你会明确告诉人们这一点吗?

          是的,也不完全是。我会给他们一个大致的规模数字作为设计参考。面试的一部分就是了解我为什么告诉你这些。

          1. 或者要求达到这个目标,我认为这也是可以接受的

        3. 在需要考虑这个问题的时候,从上下文中推断出答案的能力很重要。你不是那个把规格转换成代码的人。你是规格制定者。

          1. 我同意,但我认为我的观点是,面试的背景和期望可能与角色背景有根本性的差异,这取决于面试官。如果面试官的期望是应聘者应该提出问题来确定规模需求,那么他们应该明确这一点。对于应聘者来说,你可能会因为他们提出太多问题而给他们扣分,最终让他们失败,因为他们没有展示出知识和经验。

            1. > 对于求职者来说,你可能会因为他们问了太多问题而扣分,最终导致他们失败,因为他们没有展示出足够的知识和经验。

              我开始面试时会说:“我以项目经理和联合工程师的身份在这里,你可以向我提出想法并提问”

              利益相关者不会以“请向我提问以确保你们在构建正确的产品”作为提问开头。提出澄清性问题是该角色最基本的要求

      3. 这也发生在许多候选人只掌握了流行术语和模式,却不理解其中的权衡与细节。对于足够专业的面试官而言,知识的浅薄性会立即暴露。

        1. 识别那些重复流行术语却不理解权衡的候选人很容易。理解权衡是提问过程的一部分。

          上述评论的问题在于,它根本没有讨论权衡。它只是草率下结论,回避任何关于权衡的讨论。

          如果你这样回答问题,就无法判断候选人是真有见地,还是只是在胡扯,假装自己很聪明,因为这两种候选人听起来都一样。

          要避免这个问题很简单,就是按照问题回答,并提及权衡。试图回避问题永远不会对你有利。

          1. 是的,我可能会这样表述:“在当前负载下,我会选择最简单的方案使用X,这在一段时间内可以正常工作,直到无法继续。然后我们可以考虑水平扩展并使用Y和Z。”接着可以深入讨论Y和Z。

            毕竟,面试时理解面试官期望听到什么也是一项宝贵技能(与应对老板或客户相同)。

            1. 更好的表述是:在当前负载下,若未来负载合理预期与当前相似,我会因Y原因选择X。

              有时“诀窍”在于今天的负载并不等同于明天的负载

    8. 你将设计简洁性与问题简洁性等同起来。

      不过度设计是好事,过度设计可能导致不必要的复杂性,但当复杂性不可避免时,能够以简洁方式解决问题的能力同样重要。

      更重要的是,你尚未解释或合理化为何?

      这不需要这个QPS?哦,是吗?为什么不需要?你的魔法阈值是什么?什么时候需要它?你计划如何让团队知道那个时间即将到来?如果以后需要,你如何进行改造?那会是一个简单的添加吗?你怎么知道最大QPS不会太高,流量不会波动?如果发生意外事件导致系统过载,你的设计在没有背压的情况下如何处理,如何缓解并恢复?

      在系统设计中没有绝对正确的答案,作为面试官,你希望候选人能够展示其识别关键问题点、分析可能性、解释决策及权衡取舍的能力,等等。

    9. 我最近经历了一次类似的面试。感觉我给出的答案有一半是这样的:“你可以在这里做扩展/分片/分区等操作,但对于内部应用,我尽量避免做这些事情。”如果你面试的是有能力、有经验的开发者,他们会欣赏这样的答案(至少,我在这份工作中得到了录用通知!)

    10. 后方声音更大。

      人们似乎渴望复杂性,因为这让他们变得不可或缺?比如,如果你是唯一一个知道计费对账服务如何运作的人,他们就不可能解雇你?

      他们会解雇你。

      务实是我在工程师身上寻找的品质。只要他们明白如何划清界限(并使用队列而非cron)。不过通常这需要几年时间,而他们能说“你不需要那个,你只需要……”是值得欢迎的。不过,这可能就是我被解雇的原因。 :shrug:

      1. 我认为原因要简单得多:复杂系统更具吸引力,那些闪闪发光的旋钮、杠杆和神秘的装置令人着迷。开发人员往往会被有趣的问题所吸引,而选择过于复杂的解决方案来在抽象层面上解决这些问题,能非常精准地满足这种需求。根据我的经验,资深工程师学会了控制这种冲动,而普通工程师则能准确判断何时可以打破规则,让复杂性变得合理。

    11. 我从事软件行业已有20年,这是我第一次听到“背压”这个术语。难道我已经太老了吗?

      1. > 我从事软件行业已有20年,这是我第一次听到“背压”这个术语。难道我已经太老了吗?

        我50年前就开始写代码了(我今年63岁),所以依我之见,我们确实太老了,……

        值得注意的是,系统概念/技术在不同领域和子领域中往往有类似的名称和历史。

        如果我要向普通人解释“背压”,我可能会用这个经典笑话的逻辑来类比:

        鲍勃:今晚去Trendio(TM)吃饭吧!?卡罗尔:哦,没人去那里了,太拥挤了!

        此外,现代被视为理所当然的概念往往是之前问题或解决方案的延伸。

        例如,背压在概念上与以太网中巧妙的随机后退机制相邻。

        或者,如果你和一个数学极客或交通规划师交谈,你可能会将其与现代对拥堵的理解联系起来,包括一些奇怪的现象,比如可能通过移除道路/路线来悖论性地改善交通流量。

        我们正深陷信息时代,朝着奇点飞速前进,因此无论年轻还是年老,我们都只能看到并理解我们所经历的、正在经历的或可能经历的极小一部分。

        此时不妨联想到《加菲猫与霍布斯》漫画中我们坐在脆弱的盒子里飞速下山的情景。

        或许,正如他人所暗示的,将意识与AI融合或许能带来帮助(尽管我认为这只是暂时的)。我更倾向于将我们这些老一辈视为潜在的智慧存在,却又矛盾地处于无知状态。

        初学者的心态,尽管可能没有时间或未来去追求精通,但仍然可能令人愉悦,我认为这对调试是有用的。

        显然,这场现代人工智能的海啸正在将我们所有人推入调试模式,对吧?

      2. 背压在许多层面上都会发生,甚至在单台机器执行某项任务时也会发生。如果你有生产者和消费者在互动,而消费者无法以生产者生产的速度消费,你就需要某种方式让生产者暂停或减慢速度,直到消费者跟上。这就是反压。

      3. > 这是我第一次听到“反压”。我是不是已经太老了?

        恰恰相反,随着年龄增长,你会越来越有这种感觉。

      4. 是的

        (但别担心,就像有人对另一个术语所说:“依赖注入”是一个价值25美元的术语,用来描述一个价值5美分的概念,这个术语也有类似的情况。)

      5. 这表明你没有陷入“把每个问题都分散开来”的陷阱。我认为这与年龄无关。

        但要记住这个概念,以防你需要分散某个问题。这是一个核心概念。

        1. 我从未玩过《Factorio》也从未听说过它。听起来是个很棒的游戏,感谢推荐!

          1. 不幸的是,它太棒了。至少你在沉迷其中时会彻底了解背压原理!

      6. 服务、系统和/或数据库在故障或过载时最终会产生背压。设计背压的目的是让系统能够优雅地降级,而不是混乱地失败。

      7. 虽然有些出人意料,但如果你从未处理过某种性质的扩展问题,它可能从未出现过。

        尽管你可能熟悉其他具有相同含义的术语,如反向压力

    12. > > 面试官:“那么关于背压呢?”

      > > “对于这个QPS量级,这其实不值得考虑”

      在面试中传达这一观点有好的方式和坏的方式。

      如果面试官询问反压,他们是在提示你展示对反压的理解以及何时何地应用它。将此视为辩论问题有效性的机会,会让人觉得你在回避问题或试图唱反调。解释何时何地选择添加反压是好的,但随后你应该继续回答问题。

      这个问题对我来说触动很大,因为我曾经在一家小型初创公司工作,当时我们正面临一个独特的问题,而背压确实是解决其中一个问题的正确方法。然而,我们遇到过许多候选人,他们和你一样,对这样一个话题在初创公司中相关性表示不屑。

      如果我们已经为某个问题困扰了数月,而候选人却自信满满地告诉我们这个问题不会在我们这里出现,并轻描淡写地带过我们的问题,这绝非积极信号。

      > > 面试官:“你会如何在SQL和NoSQL数据库之间做出选择?”

      > > “差别不大。选择团队最擅长的即可”

      这是一个非常简单的问题。如果你的回答模棱两可或试图回避问题,会让人觉得你在逃避话题或刻意标新立异。这也向面试官发出警告信号,表明你可能更倾向于选择对自己来说容易的事情,而非对项目最合适的方案。

      这个问题也让我感同身受,因为我曾花数年时间让MongoDB完成一些本可以使用SQLite等工具轻松实现的任务。他们选择MongoDB的原因?因为团队对它熟悉。被锁定在围绕错误工具构建的多年遗留代码中,只因早期员工认为“因为是初创公司所以无所谓”,这简直是噩梦。

      作为面试官,让我给点建议:如果面试官提问,你应该直接回答问题。任何试图转移话题、回避问题或辩论问题优劣的行为,都让人觉得候选人要么不理解主题,要么想通过辩论问题来浪费时间。

      在解释某个主题之前,先说明何时以及为何该主题会变得必要,这非常有价值。例如,与其说“这个应用程序的QPS很低,因此我不会回答你的问题”(这不是你说的原话,但这就是它给人的感觉),不如先解释如何通过适当扩展服务器来避免需要背压,然后再回答你被问到的问题。

      1. 关于SQL与NoSQL的讨论,我的观点是:应始终从SQL开始并精通SQL,若未来确实遇到无法通过其他方式满足的扩展需求,再转用NoSQL。十有八九你永远无需切换。

    13. 如果要否定某事物过于复杂,正确的做法是指出导致其必要的具体情境,并描述在该情境下你会采取的措施。

      “背压?我认为流量不足以触发背压机制。这里故障模式是队列空间耗尽并开始丢弃消息,偶尔丢弃几条消息并无大碍。但若我们确定丢弃消息引发问题且成为常态(我们将设置可观测性),生产者可在高负载下通过轮询队列大小并返回错误给用户来处理。

    14. 不仅在面试中进行理论探讨,很多实际设计都是由所谓的“简历驱动开发”推动的。最糟糕的是,其中一些内容后来在大型会议上被作为成功且值得借鉴的解决方案呈现。

      有一次我在一家人才派遣公司工作,我们的团队被大公司聘请参与一个内部项目。两个月前,一名内部员工被指派研究该项目并开发原型。当我们开始时,所有主要设计方案已成定局。一个月后,该员工离职。后来我们查看他的求职简历时发现,他申请的职位技术栈与我们的完全一致。他获得了免费培训、一份简历和一份新工作。而我们却被这些决策束缚了三年。

      另一次,另一家大型企业的本地分支机构试图在内部争夺一块重要的蛋糕。负责人被聘请,团队迅速扩充,他们开始巩固自己的地位。随后,我们级别之上发生了系列重大权力变动,另一家分支机构提出了竞争性战略。我们组织了一场为期两天的内部头脑风暴,召集50人共同探讨如何捍卫我们的策略。我们押注蓝色,他们推销红色。生计岌岌可危。许多人坚信蓝色是正确方向,红色则是灾难的根源。两天后,我们准备了一份坚如磐石的报告,彻底否定了红色方案。但当然,大多数决策并非由技术人员或中层管理层做出,最终公司选择了红色方案,整个部门因此变得多余。没有人喜欢失去工作,所以我们的蓝色部门负责人迅速转变立场,团队成为了获胜团队的外包服务提供商。这个故事特别有趣的地方在于,这位负责人立即开始了一系列会议演讲,声称他一生都相信红色是未来,最终会超越蓝色,任何仍在使用蓝色的竞争对手都注定会在短期内失败。

    15. 降低创建事物门槛的工具使解决问题变得更容易,无需大规模投入来承担成本。生成式人工智能是这些工具之一,低代码平台也是,React也是,AWS也是,甚至电力网也是。但近年来,生成式人工智能是一个巨大的飞跃。

      我们正处于另一个周期的起点,即大量利基产品涌现后,大型Acme跨国公司凭借规模经济优势征服所有市场的阶段。这正值我们处于过去50年左右所熟知的科技周期尾声之际。

    16. 你不需要完全忘记这一点。我养成了一个习惯,即使我对当前的工作非常满意,我也会定期寻找工作机会并参加面试,也就是说,我已经进行了大量此类面试(无论是作为求职者还是招聘方)。

      除非初始需求要求过于离谱(例如在Twitter规模下构建Twitter),否则我会从最简单、最基础的方案开始。通常这意味着一台机器/虚拟机与数据库通信(甚至只是SQLite!)。如今计算和存储速度如此之快,你甚至可以将初创服务部署在树莓派上,根据工作负载不同,甚至能处理三到四位数的每秒查询量(QPS)。

      当然,你仍然需要在面试中“按规则出牌”,所以一定要明确说明,随着需求变化(更高QPS、更多功能等),你会如何调整方案。

    17. 是的,然后你得到了这份工作,却后悔了,因为他们要么有一个过度设计的鲁布·戈德堡装置,要么因为在博客上读到这种架构而产生系统羡慕,认为他们需要K8s,但实际上他们仍然无法解决所有问题。

    18. 我认为他们并非完全不希望听到这种类型的答案,但这些例子过于枯燥,没有深入解释你持这种观点的理由,而这可能正是他们担心的。每次你说某件事不值得考虑或似乎没有必要时,你应该解释清楚你为什么这么认为,以及在什么情况下它值得考虑或似乎必要,否则你看起来就像是一个对他们所问的任何类型的可扩展性都不在乎的人。

    19. 你可以说:“由于我们只有x QPS,我将选择方案A。如果我们有y QPS,我会选择方案B,但这会影响整体设计。如果你预计增长到y,我可以向你展示如何实现。”

      面试的目的是完全揭示一个人的思维过程,以便面试官充分了解你这个人。同时也要从面试官那里获取同样的信息。获取或传递较少的信息只是在浪费时间。面试官也有缺陷,可能不够擅长从你那里提取信息。

      如果你是理想的决策者,你很可能比大多数面试官更胜一筹。你被聘用是为了让他们的组织取得成功。所以,就去做吧。

      我认为,那些经常描述系统设计的人往往未能明确界定他们所处的空间,因此后续工程师无法判断原始设计师是否遗漏了某些内容,还是说原始设计师已经考虑并排除了某些内容。关键在于能够简洁地表达这一点。

      依我之见,做好这件事意味着不仅要做到正确,还要将信息传递给后人,使后续观察者能够理解原因并相应地做到正确。

    20. 谁会这么做?为什么要把事情弄得比实际需要的复杂10倍,而你本可以使用简单的方法,却能达到10倍的效果?又不是没有足够的工作要做。

      1. 谁会积累几十年的遗留代码?!

        真正的公司会。你部署第一行代码的那一刻,它就成了遗留代码。从那时起,情况就会这样发展。很快,你不得不构建与其他系统接口的系统,而这些系统本应有更好的架构和设计,但你不得不接受它们的现状。然后你的产品就成了这样,由于无需长期维护或扩展,它逐渐老化,现在有人不得不接手或与之接口,而你的产品让事情变得更复杂,这种复杂性无法一挥而就地消除。

    21. 在某些方面情况更糟。还有项目评审面试。“我们有一个基于Postgres的Rails/Django/或其他单体应用,而且不需要SPA”这样的说法在许多公司面前并不令人印象深刻。这会产生很大的动力去过度复杂化/“未来证明”事物,以提升简历。

    22. 确实如此。而且真的无法轻易判断面试官的思维方式。一位面试官认为设计中没有仓库是个错误,另一位则认为缓存解决方案让事情变得过于复杂。面试结果完全是碰运气。

    23. 如果你知道这些不是他们想要的答案,你可以合理地通过稍微修改答案来通过,同时仍然传达你的观点。

      如果你不能,你可能正在接受那些你不愿意与之共事的人的面试,你应该知道这一点。

      1. 但这些人可能是阻碍你获得这份工作的障碍,而这份工作可能在财务上或其他方面对你的人生或职业产生重大影响。在当前市场环境或根据你的具体情况,这很难被忽视。

        1. 我认为这是个红鲱鱼。你是知识工作者。你被雇佣就是为了在必要时提出异议。是的,当你说“那是个愚蠢的问题”时,人们可能会感到冒犯,但如果他们连在你以一种可接受的方式提出意见时都无法接受,那 simply 不会奏效。

          理解被问的问题。你的见解正在接受考验。提供一个不像是回避或随意猜测的答案。

    24. 对你的内容没有意见……但Kubernetes确实管理Kubernetes。

      当你开始用多个控制平面节点初始化一个高可用性集群时,这一点就显而易见了。

      K8s不是胆小的人……或理性系统设计师的菜 😉

    25. 回答他们想要的,并以“但在实际中,对于如此大的流量来说这并不重要,而且会是徒劳的努力”结尾。

      人们要求并行实现FizzBuzz并非因为它实用。

    26. 你本来就不愿意在这样的公司工作。

      1. 确实,但人们通常也不想被驱逐或断水断电。如果你需要工作,你就需要工作,而加州就业市场的数据让许多技术人员处于一种可能无法等待“合适岗位”的境地。一如既往,因人而异,世界很大,每个人都不同,等等等等。

      2. 如果你想在FAANG公司拿到高薪,就必须经历这种系统设计面试。你可以说不应该为FAANG公司工作,这很公平,但FAANG公司确实支付高薪。

        1. 所以,如果你想要FAANG的薪资,你就必须参与FAANG的面试游戏。如果你不愿意参与FAANG的面试游戏,那么也许你不应该追求FAANG的薪资。

          1. 现在不仅仅是FAANG公司进行这种面试了。

            小型初创公司现在也进行这种面试。

          2. 参加FAANG面试是获得FAANG薪资的必要条件,但并非充分条件。正如其他人所说,非FAANG公司也在尝试进行FAANG面试,我会告诉他们去死。

    27. 我觉得你可能没抓住重点。

      你的答案完全合理,但你必须向面试官传达你已经考虑过各种可能性和权衡。

      如果面试官需要“强行”从你那里提取设计选择背后的逻辑,那么很多时候这足以让你失败。

    28. 为什么有人会问低QPS的问题?这似乎会引发你之前给出的“无所谓”的回答。

      > SQL和NoSQL并不重要

      数据库实际上是除了应用程序编程语言之外最重要的架构决策。

      (证明我错了)

    29. 面试官只是另一位工程师,试图判断你是否是他们可以进行设计讨论的人。

      轻蔑的回答,假设他们是在无谓地复杂化问题,会让他们确切地知道他们需要知道什么

  2. 这是一篇精彩的文章。阅读此类观点总是令人愉悦。

    不过我有几点看法。以下内容摘自文章:

    > 避免让五个不同的服务都向同一张表写入数据。相反,让其中四个服务通过API请求(或触发事件)向第一个服务发送请求,并将写入逻辑集中在该服务中。

    这并非如此简单明了。其中的权衡远非显而易见或可接受。

    如果五个服务都访问数据库,那么你正在设计一个分布式系统,其中被消费的接口是数据库,你无需设计或实现该接口,它已内置授权和访问控制功能,并且支持事务处理和自定义查询。另一方面,如果你将一个服务设计为数据库的高级接口,那么你需要实现并管理自己的自定义接口,包括自定义访问控制和约束,并且需要自行设计和实现如何处理事务和补偿策略。

    那么,你到底得到了什么?更多的故障模式和更高的微服务开销?

    此外,五个服务访问同一个数据库是一种代码臭味。很可能这些数据库实际上是将两个或三个独立的数据库合并在一起。这种情况很常见,因为大多数服务都是通过逐步添加功能来发展的,向数据库添加一张表所遇到的阻力远小于提出创建一个全新的持久化服务。而且,这五个独立的服务是否实际上只是一个或两个服务?

    1. > 被消费的接口是数据库,你无需设计或实现它

      你绝对应该设计并实现它,恰恰因为它现在就是你的接口。事实上,这将为你的设计增加更多约束,因为现在你面临不同消费者和潜在写入者对同一资源的竞争,且可能存在不同的访问模式。此外,共享表的迁移带来的维护开销也不容忽视。最终,该表中可能包含仅部分服务需要的数据,因此你需要在数据库层面实现视图和访问控制。

      理想情况下,如果你有机会实现它,API会更干净、更灵活。大多数情况下,问题在于业务部门迫切要求更快地推出功能,这往往导致快速的权宜之计,包括直接从另一个服务访问某个数据库表,因为替代方案需要更多时间,而我们没有时间,我们想要功能,现在就要。

      但我同意你最后一段的观点。人们常常不愿意投入精力进行全新的设计或重新设计以适应不断变化的需求,而是通过在现有数据库中添加新表来打补丁,然后再添加另一个表……

      1. > 事实上,这会给你的设计增加更多约束,因为现在有不同的消费者和潜在的写入者都在竞争同一资源,且可能具有不同的访问模式。此外,此类共享表的迁移带来的维护开销也不容忽视。最终,你可能会发现该表中的一些数据仅对部分服务有用,因此需要在数据库层面实现视图和访问控制。

        以PostgreSQL为例,它可以处理所有这些挑战。

        1. 这不是不可能,而是是否是个好主意。

          通常的问题是,某个团队暴露了他们内部的一张表,而他们无法控制针对该表运行的查询类型,这些查询可能在访问模式不同时影响他们的服务。或者外部团队要求添加对拥有团队模型而言没有意义的额外字段。或者添加一些外部来源的信息。或者团队从 PostgreSQL 迁移到 S3 或 DynamoDB。这并非一个详尽的列表。API 层更加灵活,并且可以比暴露内部实现更长时间保持稳定,因为内部实现依赖于特定技术在特定方式下实现,而这些技术在共享时可能已经过时。

          当然,这在同一团队或紧密合作的团队内部并非问题。他们可以处理必要的协调工作。因此,总有一些例外情况和简单用例,其中数据库访问完全可行。特别是如果尚未建立API,而建立API可能需要更大投入,尤其是当尚不确定该想法是否可行时。

      2. > 此外,此类共享表的迁移还伴随维护开销。

        将数据类型从SQL迁移到其他语言并不能解决任何迁移问题。

        你可以用抽象语言隐藏的每一次迁移,同样可以在SQL中隐藏。数据库可以表达与应用程序代码完全相同的行为。

        1. 我通常支持SQL作为接口,但这完全错误。

          数据库在迁移行为上施加了各种各样的奇特限制,而应用程序代码无法表达这些限制(例如,如何实现事务加双写模式来迁移到使用新表,因为在旧表上添加索引所需的锁定会导致不可接受的长时间停机?可能有一些SQL引擎可以通过视图实现这一点,但大多数人出于正当理由选择在客户端解决这个问题),而且还有许多更改在没有统一的服务层覆盖数据库的情况下根本无法实现。需注意,“统一服务层”并不一定意味着“网络化服务层”,如果能防止用户绕过查询函数直接访问数据库,该层也可实现为进程内服务。

          1. > 注意,“统一服务层”并不一定意味着“网络化服务层”,如果能防止用户绕过查询函数直接访问数据库,它也可以是进程内的。

            你可以更进一步,在数据库本身实现“统一服务层”——使用存储过程和视图。

            这有缺点,比如与特定数据库管理系统(DBMS)的强耦合,以及在相对原始的SQL方言中开发困难,但可以保护数据库免受“不守规矩”的客户端的侵害,并在某些情况下具有巨大的性能优势。

          2. 如兄弟评论所提,这是一个已解决的问题。MySQL和MariaDB在索引创建结束时会对表施加一个非常短暂的锁,你不会察觉到,我保证。Postgres在使用CONCURRENTLY选项构建索引时也会这样做。

            如果你因某种原因需要将数据迁移到新表,可以使用触发器。

            如果这些方法仍无法解决问题,ProxySQL 或类似工具(你正在使用某种连接池管理器,对吧?)可以实时重写查询以实现任何需求。

          3. 是使用触发器还是并发创建索引?大多数人是否在客户端解决这个问题?例如 Percona 是否使用触发器?

    2. > 那么你自己到底在买什么?

      API 的演进要比共享数据库模式容易得多。基于对多种系统架构的实践经验,我认为这一优势远超其他考量因素,因此我不会再设计让多个服务访问同一数据库模式的系统。

      如果是在2000年代初,当时数据库技术已较为成熟而服务概念尚未普及,这种设计或许是个好主意。但自那以后,我从未见过任何系统中多个服务访问同一数据库模式而不犯错的案例(不包括那些读写路径在架构上是同一服务独立组件的系统)。

      1. 我15年前实现了一个有趣的服务。最近我听说过它。

        这个服务基本上是一个“通用集成服务”。公司希望共享一些数据,并希望以一种通用方式实现它。因此,我实现了一个SOAP网络服务,该服务接收包含SQL文本的请求,并返回行列表。这个服务出人意料地受欢迎且被广泛使用。

        我足够聪明,因此构建了一个有限的SQL语法解析器和用户界面,以便管理员可以为特定客户设置要共享的表和列。SQL查询在某种程度上是有限的,因为它仅支持一个表、简单的列集以及一些有限的条件(我特意实现了这些条件)。

        我几个月前听说这件事是因为他们告诉我,他们抓到了一名恶意人员,该人员在一家与该系统集成的公司工作,并试图进行SQL攻击。他们通过日志中的错误发现并抓住了他。

        他们的数据库在模式方面基本上已经完成并冻结,几乎不再进行演进。因此,该服务实际上具有良好的向后兼容性。当然,如果必要,简单的更改可以通过视图来支持。

        1. 那么,当底层表需要更改时,该怎么办?

          视图很好,有助于解决这种情况。但如果数据复杂、庞大,甚至频繁更改结构(DDL),视图只能提供有限的帮助。

          不过,我认为API更新协调往往比模式变更协调困难得多(因为API行为的变动维度远多于数据库查询行为),因此我通常支持多个服务共享数据库——只要在合理范围内,且明确认识到风险、得到适当论证并负责任地使用。

          1. 100%。视图甚至无法覆盖模式演进的所有用例,除非你愿意在存储过程和服务之间复制业务逻辑,但模式演进只是开始。API版本控制为你提供了更多灵活性,以演进数据的存储和访问方式。部分数据可能迁移到其他数据存储中,部分功能可能外包给第三方API,你可能需要开始支持第三方集成等。尝试从视图中实现这些——或者说,请不要这样做!

    3. 目标是尽量减少在需要更改时需要更改的内容。

      当你需要更改数据存储时,通常是为了产品或可扩展性,你必须协调对该数据存储的所有访问。

      因此:只有一个组件使用数据存储意味着更少的协调工作。

      在工作中,我们最近更新了一个数据存储。我们不得不将一些表移动到它们自己的数据库中。三年后,40多个团队更新了他们的访问方式。这是产品需求。如果这是可扩展性问题,产品可能会在没有目前尚未想象到的解决方案的情况下死亡。

      1. 一个用于数据库使用的重用代码库是一个替代方案

        1. 这将你的API层移动到你需要为客户分发和构建的客户端库中,使用他们支持的编程语言。在某些情况下,厚客户端是有意义的,但通常更容易在服务器端实现,并让客户从他们的环境中消费API,因为修复服务器比向所有用户分发库更新更容易。

          1. 我认为本讨论中大部分假设是接口的“客户”指同一组织内使用数据库实现共同业务目标的其他团队,而非外部终端用户客户。

            对于外部终端用户,当然要提供API,这一点毋庸置疑。但该API背后的内部服务交互则是一个更复杂的问题。

            1. 对于外部客户来说,情况当然更糟。但即使对于内部客户,情况也不那么简单。主要问题是,通常暴露的表并不打算作为公共接口,因此团队会引入对内部模式的外部依赖。而另一个团队可能有完全不同的目标和优先级、速度和规模、管理层和最终用户,且需求各不相同。某個時候,另一個團隊可能會開始要求第一個團隊在其內部表格中添加一些看似無害的欄位。此外,第一個團隊也可能需要進行更改以支援其自身服務,而這些更改可能與另一個團隊不相容。其他团队执行不受数据库所有者团队控制的查询,这可能影响性能。如果可能,最好约定一个 API,并避免直接依赖内部实现,即使是对内部客户也是如此。当然也有例外,例如,如果两个团队非常紧密或属于同一管理层且服务同一客户,则可能没有问题。或者,如果涉及的表明确设计为公共接口,虽然罕见,但也是可能的。

    4. > 此外,五个服务访问同一数据库是一种代码臭味。

      反驳(假设您指的是数据库集群而非模式):为每个服务单独部署物理数据库意味着,对于大多数场景而言,可靠性已从 N 降至 N^M。

      1. 从哪个角度来看?如果一个服务正常运行,但由于另一个服务不可用而无法执行任何操作,这除了增加某些仪表盘上的指标外,有什么用处?(注意,我们这里特指耦合服务,因为其含义是将写入单一数据库的操作拆分为多个数据库——分布式单体架构)。

        1. 有道理。不幸的是,我所接触过的微服务架构都是分布式单体架构——在多家公司都如此。

    5. 我认为作者的意思是,从一般角度而言,应避免不同服务同时写入,因为这容易引入竞争条件。

    6. Airflow 2 使用数据库进行协调,Airflow 3 切换到 API。

    7. >那你到底在买什么?更多的故障模式和更高的微服务成本?

      架构图中的漂亮方框。每个方框都交给不同的团队,然后,当这些团队的工程师不互相交流时,系统就不会突然以意想不到的方式失败。

      1. 在亚马逊,高层做出了一个决定,即没有人会写入共享的 DynamoDB 表。一个团队会拥有并提供 API。这极大地提高了可靠性和速度。

        1. 团队边界非常重要。如果同一个团队负责所有访问该数据库的服务,并且对这些服务有绝对的控制权,那么可以长期使用共享数据库。然而,如果涉及多个团队,这种紧密耦合就会成为问题和瓶颈,尤其是在原型设计/想法验证等阶段之后。

        2. 我无需亚马逊高层的决策提醒,就能明白迁移一个广泛共享的Dynamo实例或天啊,更改DAX设置会有多痛苦

  3. > _在查询数据库时,_直接查询数据库。_几乎在所有情况下,让数据库完成工作都比自己动手更高效。例如,若需从多个表获取数据,应使用JOIN操作而非分别查询后在内存中拼接结果。

    哦,是的!绝不要在应用程序代码中进行连接!但也要使用视图!(如果可以的话,使用存储过程)。视图是对底层数据的抽象,它本质上是功能性的,不太可能因随机原因在未来出现故障,而且如果设计得当,底层的SQL代码出人意料地易于阅读和理解。

    1. 这是导致ORM成为问题的重要原因之一。

      在SSR架构中,为每个MVC视图编写原生SQL视图/查询是构建复杂Web产品最优雅且高效的方式之一。让关系型数据库管理系统(RDBMS)承担数据处理的重任。如果你使用的是像MSSQL或Oracle这样的老旧企业级数据库,其中包含的优化机制多到你甚至无法全部记住。Web服务器应能够直接将SQL结果集插入到对应的

      等元素中,而无需为每行数据进行往返传输或执行额外的内存合并操作。

      典型的ORM实现恰恰相反——它强制使用一个严格的对象模型,且必须在所有场景中一致应用。这种设计几乎是不可变的。

      1. 大多数 ORM 都会让你将存储过程和视图映射到类,你可以拥有任意数量的模型。所以你的观点并不成立。

        作者并未提及 ORM。这感觉像是你在针对 ORM 发表个人不满,而这与作者提倡的“务实”软件设计工程理念完全相悖。使用ORM大幅减少重复的CRUD代码,然后使用原生SQL(或原生SQL+ORM进行字段映射)处理其他部分,是一种务实的设计选择。

        你可能不喜欢它们,但使用ORM进行CRUD操作可以节省大量重复且易出错的代码。是的,你可能会自掘坟墓。但这就是资深开发者的本质,要务实使用现有工具,而非自掘坟墓。

        关键在于识别模式,如果看到大量ORM查询,那很可能是代码异味,本应使用原生SQL的查询。

        1. 在 Go 中,例如,有一种混合方法是 pgx + sqlc,这基本上是最佳 Postgres 驱动程序与类型安全代码生成器(基于原始 SQL)的组合。

          https://brandur.org/sqlc

          尽管我经常只使用 pgx,但对于新项目,我会采用上述方法。

          1. 我之前对 sqlc 进行过一些探索性分析,但无论如何都无法弄清楚如何在查询中参数化排序和分组的列。

            它确实很巧妙,但我认为它并不能真正替代在实际需要 ORM 时使用的合适 ORM。再加上所有代码生成陷阱。

            我个人更喜欢Prisma的方法,它不将数据库数据映射到对象,而是根据查询返回一个元组数组(且从不进行懒加载)。TypeScript类型会根据查询动态计算。它也有自己的陷阱(如使用原始查询时没有类型)。

        2. 你描述的方式是理想的,即 ORM 只处理非常基本的 CRUD 操作,并强制你使用原生 SQL 进行复杂查询。但这并非现实,也不是它们的实际使用方式,并非总是如此。我认为有些开发者以使用自己喜欢的 ORM 完成所有任务为荣。

          我认为,如果一个应用程序使用90%的ORM代码,剩下的部分是原始查询,那么初级开发人员倾向于偏好ORM代码,并且也较少接触实际编写SQL。他不太可能成为SQL专家,但通过在代码封装下使用SQL,他应该能够成为专家。

          1. 而没有 ORM 的代码有巨大的缺点,不仅限于如果你添加/更改一个列,代码可能会在运行时崩溃,而不是编译时。

            不使用 ORM 的缺点远比不限制一些不应该编写复杂查询的开发者更糟糕。

            如果他们甚至不知道如何检查复杂ORM查询生成的SQL,那是个培训问题,不是ORM问题。

            这是我们职业的重大弱点之一,假设每个人都能自行搞清楚问题。

      2. 使用ORM时,你的应用程序代码就是你的视图。

        您可以编写可重用的普通函数作为抽象层,返回允许在查询中链式添加更多过滤条件的查询集,而在实际的 SQL 语句生成并发送至数据库之前。

        此结果无需与您最初定义的对象模型完全一致,仍可通过分组操作灵活生成字典结构。

        1. 但将 SQL 关系转换为字典集合本身就存在大量开销:结果集中的每个单元格都必须转换为键值对。而对字典集合进行垂直“切片”的常规操作,其开销远高于在 2D 关系数组中进行相同操作。因此,尽管您可能希望为结果集提供类似字典的接口,但请不要使用类似字典的数据结构。

          1. 避免使用复杂的 ORM/查询结果表示形式是有正当理由的,但这并非其中之一。

            我极少见到或听说过 SQL 查询的结果表示数据结构成为性能瓶颈的情况。在客户端以更实用方式表示原始表格结果所需的额外时间和空间,与执行查询本身所需的时间以及返回的原始字节占用的空间相比,几乎可以忽略不计。考虑到这一点,以及在处理(并修复不可避免的 bug)完全表格化结果数据结构(数组、字节)时浪费的工程时间,这是一种糟糕的建议。

        2. 不讨人喜欢的观点。ORM的定义就是“支持的数据库”功能的最大公约数。它存在只是因为人们不喜欢SQL的美学,但使用它们的成本是巨大的。

          1. 并非不受欢迎。对 ORM 的厌恶是真实存在的。我在项目中喜欢使用 SQL Alchemy 和 Drizzle,因为它们提供了免费的功能(如 Alembic 迁移和即时 GraphQL 服务器),但我仍然为大多数事情编写 SQL。

      3. 如果你的 ORM 每行都访问数据库,那你就用错了。N+1 查询是性能杀手。它们在任何现代 APM 中都容易被发现。

        Rails 使避免这一点变得容易。使用 find_each 可以批量查询(默认每次 1,000 条记录)。

        阅读这条评论的评论区很有趣。要么是很多人使用了不完善的 ORM,要么是缺乏 ORM 经验的人,或者两者兼有。

        1. 我的意思是,Rails 也容易让人在 find_each 块内意外嵌套更多查询,最终导致相同的问题。

          你的团队可以制定规则和模式来缓解这个问题,但我绝不会说“Rails让这个问题容易避免”。

          1. 这适用于任何与数据库的交互,无论是使用ORM还是其他方式。无论你选择在哪个抽象层操作,你仍然需要理解底层的复杂性。

            Rails 提供的是一些易于使用(且易于理解)的抽象层,使你能够直接解决性能问题。

            “易于使用”在这里具有高度的上下文依赖性,因为这一切都并非 trivial。

            1. 我认为像 Rails 和 Django 这样的框架的真正价值在于,它们使协作变得更容易。当你从头开始编写代码时,人们不可避免地会编写自己的抽象层,然后就无法轻松共享代码。

      4. 即使在文章中,解决方案也不是放弃 ORM 转而使用原生 SQL,而是要学会如何编写代码,使其在不需要时不会执行 100 个额外的查询。

        > 特别是如果你使用 ORM,要小心在内部循环中意外执行查询。这很容易将“从表中选择ID和名称”的查询变成“从表中选择ID”和“在ID等于?时选择名称”的100个查询。

      5. >典型的ORM实现恰恰相反——一个必须在任何地方使用的严格对象模型。它几乎是不可灵活的。

        我无法对“典型”部分发表意见,因为我的大部分经验都来自于使用 EF Core,但它绝非僵化。

        我大部分读取密集型、搜索查询都是手动编写的视图,与 EF Core 集成。这让我既能享受原生 SQL 的优势,又能使用 LINQ 进行排序、分页和过滤。

      6. 你曾经构建过这样的复杂应用吗?

        特别是,你是否需要进行测试、安全(例如行级安全)、管理迁移、变更管理(例如SOC2或其他安全框架)、缓存卸载(Redis等)、支持微服务等。

        这样的评论让我感觉像是年轻开发者第一次尝试Supabase,认为这种方法可以无限扩展。

        1. > 这样的评论让我感觉像是年轻开发者

          我并不这么认为。这里讨论的背景是避免在内存中进行合并操作,这种做法在应用程序中相当糟糕,应尽量避免。此外,不加区分地使用ORM也应避免,因为这往往会增加不必要的复杂性,导致诸如令人头疼的N+1问题,而大多数缺乏经验的Rails开发者在处理ActiveRecord时都曾遇到过这类问题。

          如果你所说的确实如此,那听起来就像是开发地狱。我可以理解数据库开发人员需要内置此级别的安全支持,但开发一个实际使用它的应用程序会让你陷入如此深的细节,以至于你几乎无法进行正常的开发工作。

          一个拥有数年经验或相当经验的开发人员会以开发复杂性和使用酷炫功能为荣,这些功能让他们感到重要。

          当开发者拥有两倍于此的经验或相当水平时,他们可能会开发框架,旨在让代码更易于开发和管理。

          而超越这一经验水平后,开发者只希望代码易于维护且不会做出愚蠢的决策,如过度复杂化。但他们知道必须让年轻开发者犯错,因为他们不听劝告,因此只能眼睁睁看着地狱燃烧。

          然后你退休或换了工作。

          1. 我不知道我在说什么,这听起来像地狱?

            我只是在谈论开发复杂网络应用程序的特性,这些特性传统上在SQL中并不容易处理。

            我特别没有提出任何框架。

            这怎么会听起来像地狱?

        2. 不是你回复的人,但我有!几年前我参与的一个Java项目使用了一个名为JOOQ(Java库)的轻量级持久层。它基本上帮助你在Java中安全地编写SQL,无需ORM抽象。对于我们的复杂企业应用程序来说,它工作得很好。

          SQL迁移?这是一个已解决的问题:https://github.com/flyway/flyway

          关于微服务?你可以使用Terraform来 provision 一个SQL数据库(例如AWS Aurora),就像你使用DynamoDB或其他类似服务一样。这与ORM有什么关系?

          Redis 呢?突然间我们需要一个 ORM 来查询 Redis,以检查缓存中是否存在某个键,然后再访问数据库?这难道是难以编写的代码吗?

          读你的评论让我感到困惑。它有一种“你不用我的方法,所以你一定是笨蛋,在玩玩具项目”的意味。

          1. 作为Alembic的旧用户,我惊讶地发现Flyway的迁移默认只向前推进,而回滚功能是付费功能。这就像豪华版车型才配备安全带一样。

            1. 我有一段时间没用Flyway了。2025年有没有更好的选择?只是好奇。

          2. 从我所见,Jooq 仅在 POJO 映射时真正类型安全,但它本质上是一个带有表达式查询 DSL 的 ORM。

            或者你可以使用记录风格的输出,但如果位置发生变化,这容易导致错误。

            无论如何,即使使用 Jooq,你仍然需要接受应用层需要承担我列出的这些要求。

            1. 我猜这只是语义问题,但我实际上同意你的观点。毕竟 ORM 就是对象关系映射。不过它确实是我在 Java 和 C# 世界中使用过的最轻量级的 ORM。使用 JOOQ,你可以完全控制 SQL 语句的格式以及查询发生的时间(避免了常见的 N+1 风险)。_大多数_ ORM都试图将查询从库用户中抽象出来。

              在我们的项目中,我们在CI管道中生成POJO,对应于新的Flyway迁移脚本。这些POJO被推送到一个专用的Maven库中。这确保了我们的对象映射始终是最新的。然后我们几乎以老式的方式编写SQL……但使用了类型安全的Java DSL。

        3. 我不明白为什么这些问题用 ORM 处理会比用原生 SQL 更容易?

          1. 为什么难以相信经过充分测试、类型安全的代码比手动字符串拼接更好?

            在你告诉我只是为了方便使用查询构建器/DSL 和对象映射器之前: 那不就是ORM吗!

          2. 这是粒度权衡的问题。

            使用SQL时,你需要显式测试所有查询,其粒度细化到字段级别。

            当你将数据映射到对象模型(指DTO意义上的,而非面向对象编程意义上的)时,你拥有更大的构建块。

            这使得应用程序更简单且更可靠。

            显然,你需要选择一个高性能的 ORM——而似乎这些帖子中的许多人都因此受挫。

            个人而言,我运行一个复杂的应用程序,开发人员可以自由使用 GraphQL 模式,且请求的 99% 低于 50 毫秒——GraphQL 通过 ORM 转换为连接操作,因此我们没有 n+1 问题等。

            1. GraphQL 的问题通常在于未优化的连接操作。您的 GraphQL API 是否对公众用户开放?您如何管理他们执行低效查询的情况?

              我最常看到的做法是通过数据加载器(在代码中合并的批量查询)而非连接操作,或者使用查询白名单来解决这个问题。

              1. 虽然这个 API 并未公开暴露,但这不会成为问题。

                关键在于在数据库和 GraphQL 中保持相同的 schema,并使用能够将 GQL 查询转换为单个查询的工具。

                1. 我观察到GraphQL的问题并非在于查询数量,而是这些查询的性能(即大多数SQL查询在没有针对特定用例的适当索引时性能不佳,但GraphQL允许用户运行大量灵活的查询)。

                  1. 是的——必须确保数据已合理索引,这是合理的。

                    但索引并不需要返回单一结果。索引将结果集缩减到数十或数百个结果是可接受的。这完全符合性能要求(… 我们的应用程序)

            2. 在我看来,这只是疏忽?你假设你的 ORM 能够正确完成基本数据映射,却不进行验证?

              1. > 你假设你的ORM正确完成了基本数据映射

                你知道,它应该正确完成。ORM在运行时因映射问题失败是没有理由的,除非是在编译时或启动时(当然,如果你在软件执行过程中修改了映射则另当别论)。

              2. 不?区别在于ORM只需验证一次,而原始查询则需要在每个使用的地方都验证。

                1. 我必须在这里回应,因为似乎达到了深度限制。

                  既然你提到了GraphQL,你可能是在将ORM与传统自定义API(由原始SQL支持)进行比较。在公平的比较中,两种版本都会做同样的事情,需要相同的必要测试。假设原始SQL版本有更多变体,只是假设它做更多事情或在架构上做得不好。这并不是公平的比较。

                  1. ORM代表了延迟组织。也就是说,有人在为你测试映射和查询生成。

                    一个例子是 Prisma。Prisma 拥有一支工程师团队,专注于优化查询生成并提供简单直观的 API。

                    不使用 ORM 意味着你必须接管这种组织工作,并测试代码库中增加的额外复杂性。

                    如果能获得显著的性能提升,这或许值得——但我尚未见过任何合理现代的 ORM 存在性能问题。

                2. 原始查询不需要在每个需要的地方重复。我不确定你的观点是什么。

                  1. 当你不使用ORM时,你会遇到更多种类的查询——这会给软件测试带来更大的压力,以达到相同的可靠性水平。

            3. > 50 ms p99

              你意识到这对任何合理的OLTP查询来说都是极其糟糕的性能,对吧?亚毫秒级(以数据库为准,不包括往返时间等)是完全可实现的,即使在高负载下。复杂查询的响应时间为2-3毫秒。

              1. 这是服务器的响应时间,而非数据库——显然除了你之外,所有人都从上下文中清楚地理解了这一点。

      7. C#的Linq基于ORM一直是——类型安全内置于操作系统功能中→运行时生成一个无关的表达式树→数据库提供程序将其转换为SQL。它支持数据库连接(除非你做了一些愚蠢的事情,比如离开IQuery领域)。

    2. 存储过程看起来像是优势,但问题在于,虽然我可以使用像Rust这样非常现代的语言编写软件的其余部分,或者更实际地使用C#(因为我的团队都熟悉C#),但如果我编写存储过程,它将使用Transact-SQL,因为这是唯一的选择。

      T-SQL在上个世纪它还算新潮的时候就不是一门好的编程语言,所以我不愿意用T-SQL编写任何大量代码。由于我的过错,我维护着一个包含大量T-SQL存储过程的软件(这些过程由某个非常喜欢这种东西的人编写,长达数页),它们简直是一场噩梦。工具链对版本控制并不重视,当你犯错时,诊断信息要么不存在,要么是像C++风格那样毫无用处的垃圾输出。

      我们雇佣了大量初级开发人员。这些人还需要被提醒不要在发布版本中注释掉代码,变量名称是供人类阅读而非机器解析的,诸如此类的事情。我们虽然没有雇佣物理学家来写软件(我在一家初创公司做过这样的事),但情况差不多。然而,我在新员工的合并请求中看到的那些“我的第一个程序”代码,其可读性远不及我们已经拥有并维护的T-SQL代码。

      1. 我只尝试过一次在MySQL中使用存储过程,当时几乎无法调试,非常痛苦。普通开发人员在数据库管理上已经面临挑战,而存储过程会进一步增加难度。

        存储过程还会增加额外风险。你必须确保它们与代码保持同步,这会让发布过程更容易出错。因此,你需要增加额外的复杂层来管理版本控制。

        我能理解极致性能/效率提升的优势,但这种提升必须非常显著才能被接受。

        1. 我是Postgres的忠实用户,理论上我喜欢存储过程(支持多种语言!),但你完全正确,从开发体验(DX)角度看,它们几乎是我最后才会选择的方案,除非它们能带来显著的性能/简化优势,并且我预期它们在未来一段时间内保持相对静态。

        2. > 存储过程还增加了另一种风险。你必须确保它们与代码保持同步,这会使发布过程更容易出错。

          这个问题很容易解决:永远不要修改存储过程。每个版本都应使用新名称。

          1. 这就是我之前提到版本控制时所指的意思。

      2. 我曾在一家采用此类系统的公司工作。应用程序代码的一半被硬编码到存储过程中,没有版本控制,且到处存在隐性影响。

        只有_一个人_负责维护并理解其工作原理。他非常聪明,但对公司运营至关重要。因此,混乱的代码会以多种方式导致系统脆弱且难以更改,

    3. 我不同意。在现代高度可扩展的架构中,我更倾向于在数据库前端(后端)进行连接操作。

      后端比数据库更容易扩展。通过简单索引(如user_id)加载数据,并在后端进行连接,可以保持数据库的运行速度。启动另一个后端实例非常简单,而数据库实例则不然。

      如果你认为连接操作必须在数据库中进行,因为数据太大无法加载到后端内存中,那么重新设计数据结构,使其能够实现。

      将连接操作移至前端可获得额外优势。这使数据高度可缓存——加载速度更快,因为需要加载的数据更少,同时释放服务器端的资源。

      1. “高扩展性”在这里非常主观,我敢打赌,99% 的企业都未达到需要担心扩展到单个 Postgres 或 MySQL 实例无法处理的规模。

        1. 在某个我参与的项目中,问题出在 ORM 生成的查询上,Postgres 认为这些查询过大无法在内存中执行,因此退而求其次在磁盘上执行。

          有趣的是,它甚至没有在所有可能的地方使用 JOIN,因为根据文档,并非所有数据库都具备必要的功能。

          这是将工作外包给 ORM 时需要注意的教训。

          1. 我既使用过 ORM,也使用过不使用 ORM 的方式。作为一条通用规则,如果 ORM 告诉你查询或表存在问题,它通常是正确的。

            我职业生涯中唯一见过这种情况的项目是一个绝对的垃圾项目。所谓的“CTO”是自学成才的,所有表都过于宽泛且包含大量空值。该公司财务状况良好,但技术水平糟糕透顶。这简直是个巨大的隐患。

        2. 可扩展性并非关键。

          这一原则同样适用于小型应用程序。

          若正确应用,应用程序绝不会因数据库查询速度慢而变慢,也无需优化复杂查询。

          此外,如果你想将应用程序的一部分拆分到独立的服务中,这将非常容易实现。

          1. 我之前工作过的最后一家公司,通过在数据库内存中执行所有连接操作,实现了非常快的查询和响应时间。而这只是在配备8GB内存的小型服务器上运行的数据库。这为垂直扩展留下了巨大的空间,在达到性能瓶颈前还有很大提升余地。

        3. 垂直扩展被严重低估了,这令人遗憾。也许是因为水平扩展在LinkedIn上看起来更吸引人。

          1. 迟早,即使是小型应用也会达到硬件限制。

            我提出的方案并没有带来太多明显的缺点。

            但它允许你避免垂直硬件扩展。

            节省成本和开发时间。

            1. 我并不完全反对你的观点,但对大多数公司来说,那个“迟早”永远不会到来。

      2. 我的制造数据每个实例大小从数百GB到几TB不等,而且我指的是活跃查询的热数据。无法重新结构化,而且在前端进行连接操作是个糟糕的主意。并非每个应用程序都是小型应用程序。

        1. 在某些情况下,这是正确的。

          但你的思路过于局限。即使是这类数据,也可以通过某种方式组织,使得连接操作不必在数据库中进行。

          这种设计总是从前端开始——通过选择如何以及哪些数据将被显示,例如在表格视图中。

          许多人认为,随时显示所有数据是唯一的方式。

          1. SQL 数据库包含十多个半独立的应用程序,这些应用程序分别处理制造过程的不同方面,例如从配方和批次到维护、废料管理和原材料库存。数据是相互关联的,但应用程序是独立的,因为不同角色的人员在使用它们。不,它从未从前端开始,而是作为一个系统开始,并通过添加更多数据和应用程序而发展。SAP 就是另一个这样的例子。

            1. 这是典型的“老派”设计。如今我不会让应用程序在数据库中直接交互。

              更推荐采用简单的服务导向架构(SOA)。每个应用程序拥有独立的数据。

              这样就能轻松避免此类问题。

              1. 这并非老派设计,而是稳健的设计。我曾与认为前端甚至服务应主导整体设计/架构的人合作过。这种做法看似诱人且初看可行,但长期来看只是糟糕的设计。保持数据结构(尤其是数据库结构)的稳定性是长期维护的关键。

                1. > 虽然看似诱人且初看有效,但长期来看这只是糟糕的设计。

                  这更像是一种观点而非论据。你能解释一下你认为设计中哪些地方存在问题吗?

                  无论如何,我认为为每个后端服务单独创建数据库的决策并非由前端驱动,而是由数据迁移和数据访问需求驱动。

                  1. > 无论如何,我认为为每个后端服务单独设置数据库并非由前端驱动的决策,而是由数据迁移和数据访问需求驱动的。

                    我认为将一个共享的企业数据库拆分为多个独立但相互通信且相互依赖的数据库,是出于减少团队和系统依赖性以提高变更能力的考虑。

                    虽然这一优势是合理的,我们在设计时有时会利用这一理念,但其缺点同样显著。将一个自然被企业多个部门及系统多个模块/功能领域共享数据的数据库拆分,会大幅增加复杂性。

                    在共享模型中,当某一关键属性(如SKU)被更新时,企业所有不同模块和功能领域会立即使用该最新且准确的主数据值。

                    在分布式模型中,跨所有区域共享此状态会带来显著的复杂性和工作量。我曾参与过采用此设计方式的系统开发,此问题常导致与时机相关的故障。

                    如同所有事物,没有单一方案适用于所有场景。我们仅在优势大于劣势时才拆分此类共享状态,这种情况虽有时发生但并不常见。

                    1. 我不同意。我大致理解“拆分”数据库带来的问题。这是过去几十年人们设计系统的方式。

                      我提议放弃这种设计。

                      拆分设计更适合现代使用场景。人们需要各种类型的数据,并且希望频繁更改所需的数据。

                      “一个”数据库无法满足所有需求——由于多个应用程序共享该数据库,你无法更改其模式。因此,你将被迫使用一种设计,而这种设计可能源自需求截然不同的时代。当然,你可以进行一些修改,但这些修改既不多,也不够根本性。

                      在拆分设计中,由于无需共享数据库,你可以随心所欲地进行调整。按需修改数据结构。以多种不同形式(包括重复数据)存储数据,以便快速查询。唯一需要保持的是对外接口(如部门等)。在此你可以使用API版本控制等机制。非常方便。

                      90年代已经过去。我们无需再受当时的限制。

                      当然,数据在每个系统中不保持最新状态可能是个问题。但如今的业务人员更倾向于接受这一点,而非无法更改数据结构(“我们无法添加新字段”、“我们无法修改这个字段”等)。

                    2. > 在拆分设计中,由于不共享数据库,您可以随心所欲地进行操作。

                      > 我们无法添加新字段,无法修改此字段

                      好,我们来举个例子。

                      假设:

                      A-ERP系统包含约30个模块(如销售订单管理、库存、采购等)

                      B-对于拆分数据库,数据库按模块拆分,所有共享数据均存在数据流。因此,商品主数据存在X个不同副本(许多甚至大多数模块使用商品主数据),每个副本包含该模块所需的数据子集。

                      示例变更:在物料主数据中添加新字段:

                      共享数据库:

                      1-更新物料主数据的数据库 schema

                      2-更新需要使用新数据字段的不同模块中的代码(根据功能需求)

                      分拆数据库:

                      1-更新所有需要新数据字段的模块中的数据库 schema(根据功能需求)

                      2-更新需要使用新数据元素的不同模块中的代码

                      3-更新每个需要使用新数据元素的模块中的项目数据流

                      我认为你低估了所需的努力程度,当你说“现在我们可以随心所欲地做任何事情”时。实际上,这种更改(这是一个非常常见的例子)所需的努力实际上比共享数据库更大,并且需要更多的协调。

                      当然,有时这样做是正确的,但绝非没有权衡的万能解决方案。

                    3. 如果你需要频繁更改数据库 schema,说明你没有充分(甚至根本没有)对数据进行建模。

                      数据库 schema 本应是 rigid 和严格的;这样才能保证存储的数据是正确的。

                      > 90年代已经过去

                      如今我们有一代开发者认为磁盘读取延迟1毫秒是正常的,认为应用程序需要自带操作系统运行,认为SQL是一种过时的语言无需学习。

        2. 一个简单有效的解决方案是数据复制,例如将关联表中的部分属性直接存储在主表中。

          我明白,对许多人来说,这属于“致命错误”,但我认为这种做法可以取得很好的效果。

      3. 除非所有表的宽度相同——或者你在SELECT语句中对常量进行了奇怪的操作——否则你无法将各种查询进行UNION,因此它们是顺序执行的。你当然可以尝试并行化这些查询,但这样会增加更多复杂性。

        如果你想要键值存储,就使用键值存储。如果你想要关系型数据库管理系统(RDBMS),那就使用其功能。过去50年里它们没有太大变化是有原因的。

    4. 微服务架构鼓励将数据拆分到多个数据库中,这使得从应用程序代码中进行正确的数据库连接变得不可能。

      然后公司会购买一个解决方案,将所有不同的数据库聚合到一个“数据湖”(或当前流行的任何术语)中,以便进行OLAP查询。当然,这没有一致性保证。

      我并不是说这永远不是正确的解决方案,但它几乎不应是首选解决方案

    5. 你确定吗?

      假设你运营一个网店,有两个表,一个用于订单,包含5个字段,另一个用于客户,包含20个字段。

      假设你有1万个客户和100万个订单。

      一个执行全表连接并获取所有数据的查询会导致传输2500万个字段,而两个单独的查询加上客户端手动连接则只需500万个字段用于订单,20万个字段用于客户。

      1. 如果你需要_所有订单_和_所有客户_,当然可以。

        但通常你只需要_部分订单_,并且需要与这些订单关联的客户信息。往往你感兴趣的订单集甚至会根据所属客户的属性进行过滤。

        是否将数据库查询结果规范化为独立的订单集和客户集,还是返回一个包含客户数据的单一连接数据集,这一决策与是否在数据库中进行数据连接的决策完全无关。

      2. 思考1对多关系的一种方式是反过来思考,即“多对一”。你不是将订单与客户关联,而是将客户与订单关联(为订单添加客户信息)。

        在查询订单时,自然会希望获取客户信息,如果你有一个名为 orders_with_customer_info 的视图,那么通过订单 ID 查询该视图时,无需任何额外操作即可获得客户信息。

        你还可以通过以下方式获得汇总数据(按客户分类的订单):

          select count(*), sum(amount) from orders_with_customer_info group by customer_id
        

        这我认为相当直观。

      3. 哪种应用程序会经常查询“所有数据”?

            1. 一旦报告需求变得严谨,你就需要构建数据仓库。因为很可能客户在报告中会希望整合多个系统的数据。如果不是今天,那也一定是明天。

            2. 这样的报告通常不需要所有数据,主要是关于前 N 条记录的查询或月度性能数据。当一个报告应用程序查询所有数据时,是因为它正在构建自己的数据仓库,因此查询通常每天只发生一次,在特定时间进行,这意味着负载完全可预测。

          1. 即使如此,数据库侧的聚合和连接也会有所帮助。

            我特别指的是“所有数据”未经过滤、聚合或连接的原始状态,正如父级分析中所示。

      4. 如今,您可以使用数据库中的 JSON 聚合来避免在原本需要大量连接的情况下返回重复数据。

      5. 我特别喜欢这个帖子中的评论,因为它证明了一切都是权衡取舍 🙂

      6. 我的经验法则是,如果是1:1关系,使用连接。如果是1:M关系,则使用独立查询。

    6. 我认为将这条规则作为初步近似是可行的,但就像所有设计规则一样,你应该足够理解它,以知道何时可以打破它。

      我曾参与开发一个应用程序,该应用程序跨多个表进行连接,导致几十条记录膨胀为数千条结果行,且结果中存在大量冗余。想象一个单一的概念结果包含来自一张表的详细信息A、B、C,来自另一张表的X、Y,以及来自另一张表的1、2、3。 instead of having 8 result rows (or 9 if you include the top-level one from the main table), you have 18 (AX1, AX2, AX3, AY1, …). 随着表的增加,情况会呈指数级恶化。

      我们改用针对不同表的独立查询。关键是,我们可以对所有查询应用相同的过滤条件,因此在存在大量顶级结果时,无需对子表执行多次查询。

      结果速度显著提升,因为额外的网络开销被查询处理效率和返回数据量的减少所抵消。而且应用程序代码实际上更简单,因为从大型连接中提取唯一子表结果是一件麻烦事。这在各个方面都是双赢,没有任何缺点。

      (后来,我们直接将所有数据放入单个表中的单个 JSONB 中,效果更好。但即使这样,这也违反了旧的规范化规则。)

      1. > 这使得几十条记录膨胀为数千行结果

        这听起来并不像是数据在概念上真正连接的地方。我猜,既然这是常见的尝试,你可能是滥用连接来试图绕过 n+1 问题。作为上述的推论,你也不应在应用程序代码中取消连接。

        1. 这是连接。没有ON或USING子句或任何过滤条件的连接会产生笛卡尔积,而这就是这里发生的情况。

      2. 如果你使用CTE和json_agg,就可以将多个独立查询合并为一个查询,避免冗余数据。

      3. 这让我想起许多情况下,即使在视图和查询中也严格遵循数据库规范化规则,即使在应该打破规范化的情况下也是如此。像PostgreSQL的array_agg和jsonb_agg这样的聚合函数在防止行数在这种情况下激增方面非常强大。

      4. 我认为更准确的说法是避免在应用程序中进行“限制性”连接,即使用连接来限制输出到子集或类似情况。

        作为一个略显牵强的例子(因为我刚起床),如果你的软件有一个功能需要从今年的所有发票中获取所有发票项,且发票地址国家为给定值,那么使用连接而非加载所有发票、发票地址和发票项并在客户端进行过滤。

        不过如你所指出的,如果你只需要加载指定记录及其详细信息,建议独立获取详细行记录,而非生成一个笛卡尔积的庞大结果集。

    7. 我对任何我设计或参与使用的系统都有一个非常严格的规则:禁止使用存储过程。

      当我面试成为一家公司的开发人员时,如果该公司将业务逻辑保存在存储过程中并拥有一个单独的“数据库开发人员”团队,对我来说这总是自动被拒绝的。

      至于不在代码中进行连接,我基本上同意。GitHub本身有一条规则,禁止使用属于不同域的SQL连接表。

      https://github.blog/engineering/infrastructure/partitioning-

    8. 我不确定是否同意。首先,这样做可能更高效。假设你需要获取1000条记录。而我们需要与一个表进行连接,而这1000条记录恰好有两个不同的外键。与其在数据库中进行连接并获取大量数据,我们可以在应用程序中进行两次查询并进行连接。其次,这使得缓存数据更加容易。假设我们进行连接的对象几乎从未改变(如某些国家信息),我们可以缓存这些数据,然后与数据库中的数据进行连接。

      这并非总是最佳选择,但有时确实是正确决策。

      1. 但作为反驳,(a)数据库本身内置了缓存功能,无需额外实现,(b)数据库知道何时需要“失效”其缓存。

        引用道格拉斯·亚当斯的话:“可能出错的事情与绝对不会出错的事情之间的主要区别在于,当绝对不会出错的事情出错时,通常会发现无法修复或解决。”

        同样地,如果你在应用程序中缓存了一段数据,因为你认为它_不会_改变,那么这只会增加一个风险:一旦它_确实_改变了,你就会遇到 bug。将缓存移至数据库层,以便它能够被正确地失效,就能解决这个问题。

        确实,如果数据库缓存不够好,应用程序侧的连接操作可能仍然更高效,但依我之见,你应该在实际对查询进行性能分析后再采取这一步骤。

    9. 我认为最关键的问题是:具体成本有多高,需要执行该操作的频率,以及速度的重要性。

      如果每个页面加载时都有大量用户执行复杂查询,尽可能将数据存储在数据库中。

      如果你需要遍历大量记录并根据某些值的组合执行操作,且这是每周报告任务,我更倾向于看到三个嵌套的foreach循环,通过大量早期退出跳过不关心的内容,而不是一个耗时两天开发且没人敢再碰的多千字节SQL语句,因为它难以维护。

    10. 你应该谨慎对待“在数据库中处理”的程度以及具体实现方式。否则,可能会出现应用程序以一种值插入,但实际保存结果完全不同的情况。

      1. 我不确定这是否是你所指,但我认为文章中缺失的一个重要内容是如何隔离业务逻辑。

        优秀的软件设计会将所有业务逻辑分离到独立的层中。这可能是一个独立的项目、模块或命名空间,具体取决于你的编程语言支持什么。将业务逻辑与SQL和Web服务器代码(控制器、Web助手、中间件等)分离。

        这样你就可以将SQL视为它本应作为的数据存储。当你在SQL中嵌入应用程序逻辑时,你正在将核心功能隐藏在一个大多数开发人员不会期望找到它的地方。这种方法还会在你的应用程序和数据库提供商之间创建紧密耦合,使得在需求变化或应用程序增长时难以切换。

        1. 这还取决于你认为“数据库中的业务逻辑”指的是什么。

          那么,你对CHECK约束有何看法?我认为这并非大多数开发者会意外发现的内容,且这些检查非常方便。

          我知道甚至有人反对外键(有时这有道理),但总体而言,我不明白为什么我会放弃Postgres中那些能确保正确性的优秀功能。

        2. 基本上是的。我认为你需要保持警惕,因为它可能会以验证数据的名义悄然渗入。

          触发器是我一直在思考的,但它被严重滥用。

          1. 我喜欢使用触发器进行验证,以维护数据完整性。

            示例:假设你有父表 P,其行被软删除(update P set _deleted=1 where id=?),以及子表 C(外键引用 P.id) 显然,在此场景下“删除级联”无法生效。因此我通过触发器模拟“删除级联”行为。

            此外,我曾在前公司因团队政治原因,看到触发器在违反某些无法通过数据库约束补救的属性时,会触发 abort(..) 取消事务。

    11. 确实存在需要在应用程序中进行连接操作的场景。

      例如,你可能希望(或有权选择)对数据库进行垂直分区,或使用不同的数据存储。应用层通常是无状态的且可无限扩展,但数据库可能成为瓶颈。

      在数据库中进行连接操作是默认的最佳选择。但我不会说“绝不在应用程序代码中进行连接操作”。

    12. 当你可以提交视图时,视图是有意义的——而数据库迁移由于其不可变的性质,是一种糟糕的实现方式。

      根据代码库采用的生态系统,使用一个好的ORM可能是进行连接操作的更好选择。

    13. 对我来说,顺序是:ORM -> 基于模式的视图 -> 视图 -> 表函数 -> 存储过程(希望不会用到后者)

      1. >存储过程是诅咒。

        详细说明?

    14. 我来这里是为了说完全相反的事情。有几次,无论我尝试什么,一个相对复杂的连接都表现不佳。而使用 goroutines 加载/拼接数据更快。所以我选择这样做。

      此外,SQL 很容易,但弄清楚索引和规划器的问题并不容易。

  4. > 矛盾的是,好的设计是谦逊的:坏的设计往往比好的设计更令人印象深刻。

    这话说得非常对。工程师的评价往往基于他们工作的“复杂性”。这种体系似乎鼓励对所有问题都采用过度工程化的解决方案。

    我认为人们对KISS原则的重视程度不够——我是在20年前读大学时第一次接触到这个概念。

    1. 这确实是个遗憾。人们偏爱复杂的解决方案,而提出简单方案往往会被视为缺乏能力,但事实是,简单方案更易于管理,这能确保整个项目的成功。

      当然,有些问题天生复杂,需要复杂的解决方案。但你的问题很可能不是其中之一,你很可能只是在开发一个基本的网页应用。

      1. 在我27年的职业生涯中遇到的最聪明的工程师之一曾建议我努力做到“尽可能简单地让事情运转起来”——这不仅是为了突破新问题的瓶颈,更是作为指导原则。这让我深有感触(对我来说,这超越了“KISS”原则),而这确实是真知灼见。

        1. 这是极端编程(Extreme Programming)的口号!我认为是由Ron Jeffries提出的,与YAGNI(You Aren’t Gonna Need It)一起,旨在提醒人们不要为计划中的功能过度设计。

    2. 偶尔,我会浏览我们的代码库,并记录那些我们很少考虑的部分——这些通常是我们早期做出良好决策的案例。

  5. > 模式设计应具备灵活性,因为一旦拥有数千或数百万条记录,更改模式将变得极为繁琐。然而,若设计过于灵活(例如将所有数据存入“value” JSON 列,或使用“keys”和“values”表来追踪任意数据),将为应用程序代码引入大量复杂性(并可能带来一些非常尴尬的性能限制)。在此划定界限是一个判断问题,具体情况具体分析,但总体而言,我力求让表结构易于人类阅读:你应该能够通过查看数据库模式,大致了解应用程序存储了什么以及为何存储。

    我惊讶于EAV(实体-属性-值)模型或在关系型数据库中直接使用JSON的缺点并未被更多人指出。

    我更愿意看到20个目的明确的表,而不是看到同事们又一次创建了一个“分类器”机制,并使用多态链接(没有实际的外键,而是使用“section”和“entity_id”等列),并将它作为一个杂乱无章的集合。你需要阅读大量应用程序代码才能希望理解它。

    每次看到这种情况,我都想换工作。我明白EAV有其适用场景,但在大多数其他情况下,EAV就是个麻烦。

    这与N+1问题、本可使用视图却生成复杂动态SQL、将审计数据存储在同一数据库并不可避免地为其编写功能(导致审计数据成为业务逻辑的一部分)等情况并无二致。哦,还有共享数据库实例且无法轻松部署自有实例,以及与Oracle数据库打交道的一般性问题。还有将本应放在应用程序中的内容放入数据库,反之亦然的情况。

    在存储和访问数据时,有太多方式会降低你的工作质量。

    1. 有一本很棒的书《SQL反模式》,作者是Bill Karwin,书中专门讨论并批评了这种反模式。

      不过,有时当我意识到无法甚至无法制定一个粗略的模式(例如,一些返回给前端的设置对象)时,我会使用Postgres中的JSONB列。然而,作为一条经验法则,如果某件事可以规范化,就应该规范化,因为毕竟,尽管Postgres中有JSON(B)的便利性和优化,它仍然是一个关系型数据库。

    2. > 将审计数据存储在同一数据库中,不可避免地会针对其编写功能,导致审计数据成为业务逻辑的一部分

      正确的做法是什么?使用独立数据库?独立数据存储?

      1. 通常,您希望审计/日志数据是不可变的,并存储在只追加的数据存储中。

        无论是使用典型关系型数据库还是更专业的解决方案(如日志传输方案),这取决于你的选择,但通常应与主数据库分离。

        如果你需要依赖已发生事件的功能,可能需要在主数据存储中存储这些事件的相关信息(但仅限于该功能所需的内容,而非像审计数据那样包含表的所有修改记录)。

        总体而言,明确划分业务领域与辅助运行组件的边界非常重要——包括日志和审计数据、分析指标、追踪跨度等内容。

        编辑:作为对自身论点的批判,我承认上述操作确实会引入一定复杂性,而在较为简单的系统中可能显得过于繁琐。但我见过当所有数据都存储在一个巨大的数据库实例中时的情景,其中约90%的整体模式大小实际上是由这些审计表中的记录造成的,而当人们发现打开某条记录的“历史”标签需要一段时间(以及任何其他引用该历史的数据,例如额外记录的可见性)时,大家都感到惊讶,这种情况也不理想。

      2. 我原本也认同GP的观点,直到看到这一部分。

        将审计数据存储在同一数据库中确实很好,因为可以以相对低成本实现事务性写入(多表更新、触发器、包含多次写入的事务等)。

        之后,当然可以将审计数据发送到其他地方并清理审计表。但让审计写入直接发送到Kafka等系统会很麻烦,因为这需要客户端逻辑同时满足两个条件:a) 实现分布式发布事件事务(在这种情况下,通过谨慎使用幂等性键、回读或事务性出站队列,比一般分布式事务更容易实现), 但这很复杂,且要求所有写入可审计表的系统都配合执行),以及b) 降低可靠性,因为现在审计存储或其消息队列也需要在线以支持每笔写入操作,同时数据库也需保持在线。

        业务逻辑使用审计数据(仅用于读取)有诸多合理原因。如果存在审计表且业务需要(例如向客户展示某项变更历史),业务逻辑还能做什么?构建另一个冗余的审计系统吗?

      3. 单独的模式,应用程序身份没有读取权限就足够了。这不像“单独的数据库”会让它变得无法查询。

  6. 一篇关于“良好系统设计”的帖子,完全专注于解决方案领域,而完全不谈论问题领域。系统设计中最困难的部分是系统向用户呈现的接口。这决定了用户如何使用它以及它能用于什么。

    软件系统用一个问题换取另一个问题。例如,_我们_将管理你的待办事项列表,提供一致性、持久性和安全性,比你自己做更好。但为了获得这些好处,_你_必须理解我们的模型,我们有待办事项、用户、列表、权限等。

    关于界面(系统向用户呈现的问题)的决策是最具影响力的,也是最昂贵的错误。如果你没有花大部分时间讨论界面,那么你就是在浪费时间讨论那些相对容易在以后更改的事情。系统中的其他一切都可以更改,而不会打扰用户。

  7. > 避免让五个不同的服务都写入同一张表。相反,让其中四个服务通过API请求(或触发事件)发送给第一个服务,并将写入逻辑集中在该服务中。

    理想解决方案:避免让五个不同的服务都写入同一张表。

    如果五个不同的服务必须写入同一张表,那么逻辑上也会有重大重叠。这五个服务真的需要分别存在,还是一个就足够了?

    考虑到实际情况,我们可以按照作者的建议操作。然而,这会引入大量协调逻辑,带来新的问题层。难道不应该花更多时间重构服务:要么为每个服务分配独立的数据库表,要么将它们合并为一个服务?

  8. 有时这确实令人头疼。几年前我参加工作面试时,曾与一家大型酒类分销商交谈——他们的挑战是通过FTP处理大量文本文件,情况糟糕得令人发笑,他们每年在AWS、Kubernetes等服务上花费$300,000。而他们本可以仅用一台EC2实例和几个shell脚本完成全部工作。不用说,我被笑出了会议室。

  9. 有状态和无状态的区别是我们划分平台基础设施与开发职责的主要标准之一。

    我知道这有点不准确,但运行在容器中的无状态应用程序很难出错。而且往往解决办法就是“杀掉它并重新部署”。只要你不因糟糕的迁移操作或数据库代码破坏数据集,大多数此类问题都能通过几次重新部署在几分钟内解决。

    我乐见这里有更多经验水平、投入时间、关注度和细致程度各不相同的人参与工作。

    与数据库或文件存储系统类似,您需要对系统操作有一定程度的经验,以避免其成为业务风险。简单来说,即使数据库运行完美无缺,也可能成为巨大的业务风险……因为没有人设置备份。

    这就是为什么我们的存储系统由经验丰富的专业人员管理,他们从事此类工作已有多年。一次严重的数据库数据丢失事故足以让企业陷入困境。

    1. > 但在容器中运行的无状态应用程序很难出错

      > 只要您没有因不良迁移或数据库代码导致数据集损坏,此类问题通常可在几分钟内通过重新部署解决。

      在这些陈述之间,你从无状态切换到了有状态,我无法跟上后续的论点。

      1. 如果你在无状态容器中搞砸了应用程序代码,那很无聊。回滚代码,你就回到了你想去的地方。这是无状态且简单的。

        如果你引入像“UPDATE billing SET prices = 0 ; WHERE something < 5”这样的迁移,这完全是一个有效的迁移,但你搞砸了状态,然后 everyone 都会陷入困境。然而,这仍然可以通过各种代码审查策略、增量发布和大量良好的开发实践来捕获。

        这仍然很简单,你可以在它影响生产环境之前捕获它,这样你就不用修复生产环境。

        如果你的数据库层管理备份,生产环境仍可修复,只是需要一两天停机时间。如果没有备份,你可能永久丢失数据,这可能导致公司倒闭。

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

    这似乎是对良好模式的过于负面评价?

        is_on => true
        on_at => 1023030
    

    当然,这有道理。

         is_a_bear => true
         a_bear_at => 12312231231
    

    不太合适,因为大多数熊在某个时间点之前就已经是熊了,而不是在某个时间点之后才成为熊。

    1. 我认为在几乎所有情况下,布尔值都是不好的选择。相反,你可以使用时间戳或整数字段(以后可以扩展)。

      在 is_a 这种情况下,几乎总是类型或类别更好,因为你很少只拥有熊,即使你最初只拥有熊,就像你很少只拥有两个状态的状态字段(例如开或关),这些状态通常会扩展以包括暂停、删除和睡眠等状态。

      因此,我通常会避免使用布尔值,因为它们在覆盖互斥状态(如活跃、已删除和暂停)时,往往会增加复杂性。我见过同一张表中同时存在is_visible、is_deleted和is_suspended字段(没有状态字段),而生成的代码和查询并不美观。

      不过,我会用整数字段而非时间戳来替换它们。

      1. 是的,我的意思是,整数确实可以存储比布尔值更多的数据。

        如果数据足够简单,你可以让整数存储表中行数据的全部含义,只要每个客户端都明白如何解释它。你可以进行位操作、编码等。

        有时仅通过模式就能理解数据的含义是很不错的。你可以通过枚举等实现这一点。

          ate_an_apple_in_may_2024
          saw_an_eclipse_before_30
          
        

        这些是我认为不需要枚举、时间戳、整数等的情况。

        1. 我从未在模式中见过类似的东西,而且这似乎也不合适——这些应该是我认为的时间戳,如saw_eclipse_at,而不是布尔值。你不应该在模式中编码业务规则(如某些特殊日期),因为这些业务规则会随着时间的推移而改变。

          1. 是的,需要一点想象力才能跟我一起走到那一步,但你的意思是直接使用saw_eclipse_at。这需要知道实体的生日、事件发生日期等信息,而在这个假设的情景中,我们并不具备这些信息。

            1. 我不会将这些信息存储在模式中,存储生日和看到日食的日期要实用得多,这样当业务方不可避免地要求查询“在50岁时看到日食”时,你可以直接回答问题,而无需添加一个“在50岁时看到”的布尔值。生日信息对关心个人年龄的业务方(如你假设的场景)也非常有用。

              通常,问题陈述的要求过于具体。系统设计的一部分工作就是拒绝建议并找出更深层次的约束条件。

              这个例子很好地说明了为什么布尔值通常是个错误。

              1. > 这个例子很好地说明了为什么布尔值通常是个错误。

                这不是。你正在忽视假设的限制条件来支持你自己的假设论点,而这个论点显然是某种变体,即“告诉人们他们是错的,然后对虚构的数据进行推断。重复这个过程,直到你得到理想的‘系统设计’”

    2. 如果你字面理解这个陈述——本质上将布尔值存储在数据库中是一种不良做法——那么他是正确的。

      尽管我甚至不确定这是否是一个普遍适用的良好原则,即使在on_at的情况下;如果你真的关心这类问题,你应该将其正确存储在某种审计表中。将布尔值转换为时间戳更像是一种奇怪的懒惰 hack,实践中可能并不实用,因为只有随机子集的数据会被这样跟踪(布尔数据类型绝不是决定是否值得跟踪更新时间的关键因素)。

      它之所以被提议,很可能只是因为它是“免费的”——你可以将时间戳偷偷塞进布尔值中而无需额外列——这可能无意中节省了一些 effort;但并非因为它是一个全面解决其试图解决的问题集的完整方案。

      我对软删除也有同样的怀疑——我相当确定它在实践中毫无用处,只是为了避免进行 Proper 审计而采取的思维懒惰解决方案。比如你肯定不能直接恢复已删除的数据,它也无法解决更新历史问题,所以你真正保护的只是避免意外批量删除被立即发现?这恰恰是备份的一半意义

        1. 或许观点正确,但缺乏充分文档支持。

          在该文章所述的许多场景中,客户端(如ORM等)都能很好地处理软删除。

      1. 审计表在设计和维护上的编程工作量较大,且由于写放大效应(所有插入和更新操作都会额外写入审计表)会带来性能开销。而将布尔值转换为时间戳是免费的。在行中包含时间戳(包括created_at和updated_at)在部署了 bug 并损坏了一些行时,可以真正节省时间,例如需要退款在特定时间段内创建的订单。

        1. 审计表是一个愚蠢的概念,因为它意味着在常规的“非真实数据源表”之外,额外添加一个“真实数据源”,而且只有当程序员有空处理时才会实现(就像文档或日志记录等其他被忽视的功能一样)。

          1. 这对我来说没有意义——如果常规表没有捕获真实状态,那么基于它们的审计表也不会神奇地成为真实数据源。

            1. 完全正确。如果它们一致,那么引入第二个真实数据源就没有意义。如果它们不一致,那么你信任哪一个?

          2. > 审计表是一个愚蠢的概念,因为它暗示在常规表之外额外添加一个真实数据源,

            常规表才是真实数据源,审计表只是记录了哪些数据何时发生变化的历史记录。

            1. 如果余额显示$30,而审计表显示有两笔$20的存款,该怎么办?

              1. 如果你将事件记录为意图,那么事件表就是真实数据源,而数据库的状态实际上是状态的快照。原则上,你应该能够重放事件以生成当前状态。如果计算错误,去修复你的计算器,假设事件表没有错误,然后重放。

                如果你存储的是数据库更改日志(通常所说的审计表),例如

                (表名, 列名, 旧值, 新值, 时间戳)

                那么数据库状态是唯一真实来源,审计表只是记录了所有导致该状态的更改。

                无论哪种情况,都只有一个真实来源。

              2. 这与没有审计表且余额错误的情况相同。你需要声明某种事件并调查直至找到这个关键 bug。希望你能通过审计日志确定正确答案并修复所有人的余额。

                如何判断该信任哪一个?通过阅读代码并找出 bug 的原因。

        2. 没错。仅仅因为更容易找到被删除/受影响的实体,这一点就值得了。

        3. > 在行中包含时间戳(包括created_at和updated_at)在部署了漏洞并损坏了一些行时,确实能救命,例如需要退款在特定时间段内创建的订单。

          但这就是我的观点。你正在主动决定对重要事件进行时间戳记录(这里并没有将布尔值转换为时间戳);布尔值转换为时间戳并非在所有情况下都适用——布尔数据类型本身并不能作为判断是否需要进行时间戳跟踪的有效信号。

          要么主动思考并选择是否跟踪这些特定更改,要么直接采用相应的跟踪机制。这种无脑的布尔值转时间戳的转换仅因“何乐不为”而被建议,而非因为它是常带来良好结果的良好实践。

          审计表当然只是在决定“所有变更都重要”

          1. 设置布尔值往往是一个你希望跟踪的重要事件。(具体来说,当它是一个指示事件发生标志时,例如,一旦设置,它很少甚至从未被取消。)当然,它并不适用于每个布尔列;没有什么是一刀切的。当然,你需要理解你的模式和你要解决的问题。这并不意味着它是无脑的,也不意味着它不重要。它确实是一个良好的模式,确实会带来好的结果。如果它是机械的,那么你建议在审计表中跟踪每个更改,以及你批准的created_at和updated_at字段,同样是机械的。

            目前,我认为我已经清楚地阐述了使用时间戳作为布尔值的简单而现实的优势。但我还没有听到你为什么不应该这样做。

            1. 原始前提,来自TFA,是

                  另一个是针对Twitter优化的“如果你在数据库中存储布尔值,那你就是个糟糕的工程师”的巧妙技巧
              

              即普遍适用性。我的观点是:这样做,在大多数情况下,并不会追踪你真正想追踪的内容,因为布尔值并非决定追踪价值的核心因素(而许多需要追踪的内容本身就不是布尔值)。它可能偶然捕获有用的跟踪信息,但这更多是偶然而非刻意为之。因此,普遍适用性显然是错误的,而TFA有条件地应用它是正确的。

              你似乎同意这些观点。我并非主张,如果你需要跟踪布尔值的时间戳,将布尔值转换为时间戳是一种糟糕的做法。如我所说,普遍应用只是因为这样做是免费的。但免费并不意味着有用,既然你对此也表示认同,我不知道这里是否真的存在争议。

              1. 我认为我们在设计模式方面达成共识的程度远大于分歧,但我的狭义分歧在于:a.) 大多数情况下,时间戳和枚举类型比布尔值更优(无需评判使用布尔值的人是“糟糕的工程师”,这并非职业失误),以及 b.) 这是一种正当的行业技巧,而非“机械式操作”。

                我同意,仅凭Twitter上的氛围草率地拼凑一张表而不进行实际设计,只会像停摆的钟表一样运作,但这本就是不言自明的。我倾向于忽视那些归因于模糊的Twitter用户而非具体个人的观点。当你将人们的观点混为一谈时,会将其立场简化为刻板印象。若对那群人进行调查,几乎所有人都会有更细致的看法。

    3. 不过为什么要把布尔值当作特殊情况,为它们保留时间戳,而对于整数却不这样做,比如这个模式:

      isDarkTheme: {timestamped} paginationItems: 50

      我可以看出深色主题何时被激活,但无法看出分页何时被设置为50。

      此外,我也无法看出深色主题何时被禁用。

      这似乎是一个简陋的变更日志。可能有使用场景,但我目前想不出具体例子。

    4. 布尔值占用空间更小,这对某些工作负载而言是重要考量。例如,你可能需要预先聚合大量数据以支持一组不关心关联时间戳的分析查询。较小的数据类型在存储和查询执行方面都更高效。

      此外,有些情况下存储布尔值是合理的。例如,如果布尔值表示一个结果:

          process_executed_at timestamp not null
          process_succeeded boolean not null
      
      1. 布尔值不太可能带来更好的利用率,节省的空间很可能被填充消耗掉。大多数人不知道如何使用结构打包来创建一个在填充后实际上更小的行(尽管这并不难,任何人都可以学习)。列通常是根据哪些功能首先发布来排序的,而不是根据对齐(这是为了最小化填充所必需的)。

        我确实尽力对列进行打包,但这是一种脆弱且可能过早优化的做法。不如选择一种防御性方案,每行多消耗约7字节(在Postgres中)。

    5. 所有这些通用建议都毫无用处,需要数百万个星号。

      良好的系统设计是为当前问题设计最适合的系统。

      1. 良好的系统设计就是设计一个运行良好的系统。

      2. 这更加普遍,需要再加一百万个星号。

    6. 我认为在这种情况下,你可以有一个枚举值,其中包含“熊”以及你正在查看的其他类别。

      1. 当然,但这是为了演示目的,展示某些数据具有其他含义,这些含义不依赖于时间的实例化状态。

  11. 我唯一知道的“良好系统设计”是它不存在于抽象层面。询问架构是好是坏是错误的问题。真正的问题是:它是否适合目的?它是否帮助你实现实际需要实现的目标?

    我可以挑剔文章中的个别点,但这忽略了更核心的问题:前提本身有误。

    不要追逐关于好设计或坏设计的通用建议。首先理解你的需求,然后设计一个满足这些需求的系统。

    1. …这就是如何实现一个好设计(至少目前是这样)。

  12. 既然作者赞扬了数据库的正确使用,并提到了事件总线、后台任务和缓存,我强烈建议查看https://dbos.dev,如果你使用Python或TypeScript后端。DBOS很好地解决了简单和复杂系统中的常见挑战,并可以消除运行单独服务(如Kafka、Redis或Celery)的必要性。最佳之处:DBOS 可作为依赖项使用,无需部署独立服务。

    一周前在此讨论过:https://news.ycombinator.com/item?id=44840693

  13. 关于日志和指标的建议很好。

    我一直在点头同意关于状态和推送/拉取的讨论,但这一部分引起了我的注意,因为我之前从未见过如此清晰的阐述。

    1. 是的。每个人都应该花一点时间设置日志记录和指标。这就像测试一样,从0到1个测试在组织中是心理上困难的,但从1到1000个测试后,你会想“我以前是怎么没有这个的”。Grafana有一个不错的免费层,或者你可以自行托管。

    2. 日志记录部分完全正确。我曾多次想过“真希望我当时记录了这些日志”,然后在遇到问题甚至事故时,不得不引入这些日志。

      1. 这是一个平衡问题。过多的日志会增加成本并减慢日志搜索速度,无论是搜索工具还是人类在同一条跟踪记录中查看100条日志时都会如此。

        1. 关键在于要积极记录日志,然后积极过滤。日志只有在无限期保留时才会变得昂贵。接收日志本身并不昂贵,短期保留也不会造成太大负担。但如果每天积累数十GB的日志,成本会迅速攀升。通过积极过滤,就能避免这个问题。当你需要日志时,临时更改过滤器要比在系统中添加大量临时日志并部署它们容易得多。

          指标也是如此。大多数情况下它们并不重要。但当它们重要时,有它们在就很好。

          基本上,日志记录是可观察性中容易且廉价的部分,而过滤和搜索的能力使其有用。许多系统在这方面做错了。

          1. 不错。我打算进一步研究过滤机制。

        2. 没错。但作者提出的“记录所有可能被用户质疑的重大业务逻辑决策”的思路也颇有道理。

          1. 是的。我也喜欢断言的想法。当断言失败时进行日志记录。然后收到通知进行调查。

  14. 他似乎没有提到康威定律或团队拓扑,而这些也是系统设计的重要组成部分。

    1. 唉,尽管令人遗憾,但此类建议通常适用于新项目,因为此时你仍有自主决策的空间。

      出于政治原因,如果你加入一个拥有数十亿微服务和大量复杂架构的团队,你很可能无法获得批准或时间来引入简化方案。或许我只是被当前的工作现实所同化了。

      1. 在大公司中,确实存在“只见树木,不见森林”的问题。我怀疑是否有架构师能全面理解整个系统,从而知道如何简化它。甚至连“更简单”应该是什么样子都难以确定。

    2. 你应该让团队适应架构,而不是让架构适应团队。

      我的前博士导师,他兼职担任该领域的顾问,用一个简洁的缩写词来概括这一概念:BAPO。即业务(Business)、架构(Architecture)、流程(Process)和组织(Organization)。其核心思想是:最终实现最优业务模式,为该业务设计最优架构与系统,仅保留该架构所必需的最小手动流程,以及一个能够高效执行这些流程的组织架构。因此,设计与工程应遵循这一顺序进行。

      大多数公司却反其道而行之,最终导致业务被一种架构所限制,而这种架构只是为了匹配组织结构图中多年以前所要求的流程,这种做法在逻辑上毫无意义,除非从组织结构图的历史背景来看。如果你作为顾问来解决这种情况,理解到你所发现的问题很可能就是因为这个原因而产生的,这会有所帮助。我曾遇到过这样的情况:我被请来解决一个技术问题,但立即发现问题的唯一原因在于组织架构图本身就是个笑话。这可能有点尴尬,但如果处理得当,也能带来可观的收益。在开始之前提出正确的问题非常重要。

      扭转这种局面意味着从业务端出发(资金来源在哪里?我们能创造什么价值?等),找到能实现这些目标的解决方案,然后再确定流程和组织需求。许多公司最初的组织结构相对优化,但随着外部环境变化,它们却忘记了适应这些变化。

      因为团队结构而采用微服务是一种经典错误。你只是将组织低效固化了下来。在尚未创造任何业务价值之前。如今,你的组织延迟与网络延迟相匹配。这可能只是因为团队A无法信任团队B合作。即使现在是优化的,它会一直保持优化吗?

      如果你要将系统拆分为(微)服务,必须基于合理的业务/技术原因。例如,数据处理靠近数据源更经济,缓存提升效率使系统更快更便宜,将系统部分物理部署在客户附近可降低延迟等。但仅仅因为团队A无法与团队B合作就引入网络延迟,这是根本性的愚蠢。你为什么会有这些团队?这些人都在做什么?为什么?

      1. > 我以前的博士导师,他兼职做这个领域的顾问,用了一个很好的首字母缩写来概括这一点:BAPO。业务、架构、流程和组织。

        这很搞笑,但也不令人惊讶,因为这是来自学术界和咨询界的组合。

        业务/使命/产品必须优先,绝对如此。

        但在流程尚未明确前就过早设计系统架构和自动化,必然导致过度设计并浪费所有人的时间,除了你按小时付费的顾问。

      2. 我所做的大部分工作,都是设计子系统——旨在集成到更大结构中的组件。我倾向于采用模块化方法(不是微服务——模块)。

        我使用的首字母缩写是“S.Q.U.I.D”[0](_S_简单性,_Q_质量,_U_明确性,_I_完整性,_D_文档)。

        但我所做的大部分工作与他所写的内容不同,因此这里可能不相关。

        [0] https://littlegreenviper.com/itcb-04/#more-4074

  15. 我想补充的一点是,一个设计良好的系统往往是经过优化以适应变化的。服务保持静态不变的情况很少见;毕竟,浏览器和库会定期更新。因此,当开发人员接手一个功能请求来添加或修改XYZ时,应该能够轻松理解该更改对系统的影响,并具有可预测的副作用,理想情况下也应易于修改。

    1. “优化以适应变化”只有在能够预测即将到来的变化时才真正有效。

      用于这种“优化”的常见工具往往会增加系统的复杂性并降低其性能。

      例如,一个仅包含键值对的单表数据库非常灵活且“优化以适应变化”,但它通常会带来较低的性能(在大多数情况下)且更难理解。

      我经常看到人们(包括我自己)过早地进行抽象化(接口、额外表等),因为他们认为这是“优化以适应变化”。然后那部分代码要么永远不会改变,要么以一种他们的抽象无法覆盖的方式改变,要么在应用程序成熟后才发现更好的抽象方式。那么这部分代码最多是浪费空间(通常需要重写,但没人有时间去做)。

      当然,说“永远不要抽象”也是愚蠢的。我几乎总是认为抽象I/O是值得的,这样我就可以轻松添加日志记录、双写入或模拟它。当变化显然即将到来时,提前规划是有意义的。

      但通常我发现,尽量保持大部分计算为纯函数(易于测试)、在输入输出路径中尽可能少做事情(它应该只是持久化或打印内容,以便我可以模拟它),以及编写明显可删除的代码(只做一件事,以便我可以调试它),如果必要的话,再用更好的抽象替换它,这样对我最有利。

      1. >“优化以适应变化”只有在你能预测即将到来的变化时才真正有效。

        函数式编程是模块化程度最高、因此最适合变化的编程范式。它是目前最好的选择,但其适用范围有限。

    2. 如果你在开发过程中实际进行测试,你就能在一定程度上免费获得这一优势。

      当我的服务需要存储和检索作为其行为的一部分时,当然我会首先使用哈希表来支持它。

      一旦我知道它满足业务逻辑,我将开始调整难以更改的内容,如数据库模式和迁移。

      在完成并测试逻辑后,我将对实际访问模式有更清晰的了解,从而能够设计良好的表和索引。

    3. 虽然我不认为应该把自己逼入绝境,但过分追求可变性会带来大量抽象复杂性,这需要时间,而时间就是金钱。

      通常,我们可以使用多个更便宜的专用服务来完成某件事,这比让单一服务变得越来越万能要好得多。这也意味着你更容易赢得合同,因为你可以以具有竞争力的价格定价

    4. 有些系统是设计来持久的,而有些系统是设计来适应的

    1. 我喜欢阅读这本书(它很短),尽管文笔非常,嗯,独特 🙂

  16. 用时间戳替换布尔值有时是个好主意,但将其视为“终极解决方案”并不太有建设性,我认为。

    添加一个单独的表,其中记录的存在表示“true”,这样可以记录相关状态而不必复杂化主表。

    有时布尔值正是你想要的。

  17. 作为系统架构师、软件工程师和系统工程师,我看到这些帖子时,所谓的“系统设计”似乎将系统设计与软件设计混为一谈(文中提到的软件是整体系统中的低级组件)

    1. 我很高兴我不是唯一这么想的人。这些都是细节问题。关于人类的讨论在哪里?他们可能是你系统中最重要、最混乱、最需要精心设计的部分。

      在原帖中稍有提及:

      > 良好的系统设计是什么样子的?我之前写过,它看起来并不令人印象深刻

      这是因为你的系统中有人类!其他开发者!未来的你!你不得不 resort to 像“简单 == 好”这样的启发式方法,因为你只看到了整个系统的一小部分。

      再往外看,你就到了真正的用户。他们如何与系统互动?如果你实现了一个速率限制器,当用户触发它时,他们会如何反应?他们会疯狂刷新页面吗?打开更多标签页?使用手机?他们会对系统产生奇怪的迷信吗?他们会疯狂拨打你的客服热线吗?你对“蜂拥而至”的用户群体的响应是否考虑到了客服热线被DDOS攻击的次生影响?

      1. 完全正确。系统级别的设计是社会技术性的:人类及其反馈循环与技术本身同样重要。许多“良好”的设计会崩溃,因为它们忘记了系统边界并不止于代码库。

  18. > 但在大多数情况下,复制延迟可以通过简单技巧绕过:例如,当你更新一条记录但需要立即使用它时,可以在内存中填入更新的详细信息,而不是在写入后立即重新读取。

    我对此感到非常困惑——这真的需要说明吗?人们真的会在写入后立即重新读取吗?只要确认写入成功且数据中没有会被SQL触发器修改的内容,立即读取的意义何在?难道不能直接将数据库的“写入成功”响应作为更新内存中数据的许可吗?

    1. 他们确实经常这样做。我们面临写入压力,团队坚持必须读取写入内容。不,不需要这样。有其他选项,而且这些选项并不复杂。

    2. 至少你需要读取数据库生成的ID,否则如何实现编辑等功能?

      仅在写入后读取ID对大多数系统来说已足够。

      如果你遇到性能问题且此类更改可能解决问题,当然可以。切换到应用程序生成的ID,并为其他可能出现的问题添加解决方法,同时跳过读取操作。但如果你不需要这样做,我看不出来为什么要大费周章。

      1. > 至少你需要读取数据库生成的ID,否则如何提供编辑等功能?

        我职业生涯的大部分时间都使用MSSQL,但它可以在插入操作后作为插入成功的确认信息提供ID。

        无需显式单独读取。

    3. 这是为什么会发生的原因:

      很多时候,你传递给writeAPI(obj)的数据结构与从readAPI(obj)返回的数据结构不同——即使包含的信息相同!

      没有人愿意进行这种数据结构转换,因为这可能导致遗漏边界情况或打破下游消费者对数据结构的隐含假设。

      然而,这种转换已经在readAPI()函数中完成。因此,不管延迟和吞吐量如何,让我们这样做:

        writeAPI(objects)
        objects = readAPI(objects)
      

      需要明确的是,我指的是我们都熟悉的典型臃肿数据结构:20多个字段,不同服务对同一字段有冗余定义,有时字段为空,此时需回退到通过其他方式计算该字段。而这种hacky实现源于去年一次sev1级别的紧急bug修复,此后从未被重新审视并“正确”修复。有一个工单在某个地方挂着,但负责人已经离职。

  19. 我确实能感受到那种“令人失望”的因素。我从事政府软件开发已有10多年,我非常清楚一个令人失望的代码库是什么样子,首先,它上面有我的名字。

  20. 我很好奇作者为何对CQRS持负面态度,却又给出这段经典的CQRS建议:

          >这在实践中意味着有一个服务负责管理状态(即与数据库通信),而其他服务则执行无状态操作。避免让五个不同服务都向同一张表写入数据。相反,让其中四个服务通过API请求(或触发事件)向第一个服务发送请求,并将写入逻辑集中在该服务中。
    
  21. 总体而言,我同意作者关于系统设计中简单性更优的假设。然而,对管理队列的频繁且疲惫的抵触情绪并不成立。

    > 有时你希望自行实现队列系统。

    我从未想过要这样做。

    > 例如,如果你想将一个任务排队在一个月后执行,你可能不应将项放入Redis队列。

    这一具体需求更像是cron任务的用例,而非队列用例。

    > 在这种情况下,我通常会创建一个用于待处理操作的数据库表,其中包含每个参数的列以及一个scheduled_at列。然后使用一个每日任务检查scheduled_at <= today的项,并在任务完成后删除它们或标记为完成。

    到目前为止,我认为作者并不清楚何时以及为何使用队列。对于一个严格按计划发生的事件,必须在 day = (today + N) 时触发,这种方法似乎可行。然而,如果你将此方法用于典型的队列用例,你最终会在数据库中重新实现一个队列(且实现得并不理想)。这通常比使用可用的管理型队列服务复杂得多。所谓更复杂,指的是代码行数更多以及值班负担更重。此外,随着业务增长,托管队列通常需要极少的人工干预。队列之所以优秀,在于其简单性——无论处理低流量还是高流量。这是一种“开箱即用”的技术,往往能提供大量实用功能。

    我并不认识这位作者,但之前遇到过这种工程哲学。这是那些想法未经成功企业长期现实考验的工程师常持有的观点。

    这种建议可以卖给99%的失败初创公司,而由于它们失败的原因与建议无关,你永远不必证明自己错了。

  22. 文章开头批评了缺乏上下文的通用规则:

    > 即使是好的系统设计建议也可能有问题。我喜欢《设计数据密集型应用程序》,但我不认为它对工程师在系统设计中遇到的绝大多数问题特别有用。

    但它在接下来的建议中仍然坚持同样的做法。它还提到:

    > … 在这里划清界限是一个判断问题,取决于具体情况,

    并立即补充道:

    > 但总体而言,我希望我的表格易于人类阅读 …

    这让我觉得是在说“我将忽略所有情境的差异,而是将我的方法强加给所有人,并且假设大多数人面临与我相同的问题”。这比开头被批评的那本书更糟糕,因为那本书至少在标题中包含了“数据密集型”的字样。

    这其实很容易解决。作者可以描述他们日常工作中遇到的典型场景。他们每天处理10个用户?100个?1000万个?流量是多少?有多少工程师?团队/公司的状况如何;FIXMEs是否会变成修复,还是会被视为功能?等等。

    最终,如果没有设定基准,许多工程师会开始互相指责,否定对方的观点,因为这些观点不符合他们的实际情况。这种推理可能有道理,但在那之前,它被视为“不相关”,因此会引发反对或辩护。

    1. 我认为这只是平淡无奇的文字。如果他只是说“让表结构更易于人类阅读”,评论区会指出在某些具体场景下这并非良策或实际操作中行不通。例如,若需要大量用户可控的元数据,数据库未必能通过单一表的字段来容纳。但必须谨慎对待。

      https://thedailywtf.com/articles/Soft_Coding

  23. 我认为这是一篇非常好的文章。即使你不同意其中的一些具体观点,文中给出的建议非常具体、务实,并且我认为可以根据每个项目的具体情况进行调整。

    关于状态,在我当前的项目中,问题并非出在状态本身,而是当需要同步两个有状态系统时。每次出现双向信息流,都会带来麻烦。当然,解决方案是维护单一数据源,但对于UI应用程序来说,这有时相当棘手。

    1. 同步和复制逻辑上属于同一整体的复杂状态确实是疯狂之举。这就是为什么微服务如此糟糕。当然,这取决于人们如何使用它们。

      单调状态优于可变状态。如果必须分布状态,请考虑所有权。谁拥有它?例如,由移动客户端拥有状态并允许用户调整并无不妥。然后你可以将其同步到后端,但它们仅作为读取器/监听器,绝不应直接控制状态。

  24. 许多后端工程师对基础设施着迷。

    我见过工程师让服务器启动 Lambda 执行异步任务,而这些任务本质上只是数据库调用。

    因此服务器本质上是在等待 Lambda,而 Lambda 又在等待数据库。为什么?为什么不能让服务器直接等待数据库?

    这就像我付钱让一个人替我排队,而我却在等待他。为什么?你本来就在等待啊!而你只是花钱让另一个人和你一起无谓地等待,为了什么?

    当我告诉工程师,你可以直接启动一个协程,或者在启动新服务器之前分配一些核心……他看着我就像我疯了一样。他说我做的事情太底层了,就像汇编语言编程。深入底层,而Lambda函数成本太低,无关紧要。

    如果你在读这篇文章并想,哇,那个工程师说得对,那么这篇文章中的这句话就是指你:

    “我经常是孤军奋战。工程师们看到复杂系统中有许多有趣的部分,就会想‘哇,这里有很多系统设计在发生!’事实上,一个复杂系统通常反映了良好的设计缺失。”

  25. 精彩的文章。还有一些想法

    除非绝对必要,否则要非常谨慎地打破事务性。这是我过去几年看到的绝大多数问题的根源。

    保持两个不同系统同步非常困难,如果没有真正的必要,不要这样做。

    单体架构非常优秀,除了单体架构外,绝对没有必要运行微服务或其他任何服务。我所在的公司仅凭单体架构就实现了数十亿美元的收入。不过,当某些逻辑必须迁移到其他服务时,那意味着你的公司已经非常成功了 🙂 。

    关系型数据库的功能远超你的想象,且绝对能够实现良好扩展。

  26. 楼主首先需要明确:

    > 如果一个系统采用了分布式共识机制、多种事件驱动通信方式、CQRS 以及其他巧妙的技术,我怀疑这是否是在弥补某个根本性的设计缺陷(或者系统本身就是过度设计的)。

    文章后文提到:

    > 尽可能将读取查询发送到数据库副本。典型的数据库架构通常包含一个写入节点和多个读取副本。尽可能避免从写入节点读取数据会更好——该写入节点已因处理所有写入操作而足够繁忙。

    这与CQRS不是同一回事吗?

    1. 某种程度上是,但CQRS可能主张使用独立的读写模型。这可能类似于写入者发布一个事件,该事件被用于更新读取模型的组件消费,而查询直接发送到读取模型。是否属于过度设计取决于具体问题和负载。

    2. 我以为CQRS是一种代码模式,用于将查询模型与命令模型分离,而非指定数据的读取或写入位置。

  27. >你有两个选项:失败时开放并让请求通过,或失败时关闭并用429错误阻止请求。

    如果软件断路器的比喻是模拟物理断路器,那么这两种情况似乎是相反的。物理断路器断开时,既不危险也不导通电流。

    1. 同意,我不明白为什么你会被点赞。如果有人告诉我他们的虚拟断路器“故障打开”,我会假设它在故障时停止处理数据。

  28. 当系统人员讨论系统设计时,他们讨论的是整个价值生成系统,而不仅仅是软件或硬件。这通常涉及人员,并引入了康威定律等问题。如果团队在工作流程上缺乏独立性,无论架构师如何设想,最终都会产生一个单体系统。例如,如果存在两组用户,其监管和组织需求存在差异(负责维护公民登记的人员与负责发放身份证明文件的人员),那么无论合并为一个数据存储是否合理,最终都会有两个独立的数据存储。

  29. 非常好的文章,一针见血!

    我好奇作者为何省略了测试、文档和质量保证工具设计。在我看来,编写一个合适的phpcs或其他工具,确保团队成员以一致的方式编写代码至关重要。没有文档,我们会忘记为何做出某些决策。而没有测试,重构将是一场噩梦。

    1. 尤其是考虑到使用 Claude Code 等工具可以更快地生成文档和测试(当然,还需要人工修订)。

  30. 良好的系统设计是枯燥、显而易见且完全无趣的。这就是为什么许多花哨或流行的技术最终会导致糟糕的系统——它们吸引人是因为其巧妙性或智力含量,这些往往很有趣,但新颖/引人入胜/智力刺激的内容通常不是系统所需要的。

    一个好的系统需要尽可能容易理解和解释,一个好的系统设计是如此简单,以至于一个笨蛋也能理解它。唯一偏离这一原则的情况应源于其他要求,如存储、性能等。

  31. 我同意文章中大部分内容(这确实很少见,我必须承认:))。但有一点我认为有些过时:总体而言,是否从副本读取与是否使用缓存是同一个决策:这是一个(相当重要的)权衡。过去由于硬件限制,你没有太多选择。然而现在,你可以拥有数百个 CPU 核心,因此所有这些 CPU 都可以非常忙碌地进行读取操作。写入操作显然有开销,_但_请注意,所有写入操作最终都会被串行化,_而且_副本也需要处理这些写入操作。

  32. 我倾向于使用NoSQL类型的数据库,因为在DDL中定义表,然后在代码中再次定义表似乎有些重复,并且会减慢更改速度。但想法是代码定义表。不过,听到一些这种做法的缺点会很好。对于非常关系型的事物,能够编写连接查询以便数据完全重复是有意义的,但我的理解是,大多数数据库引擎已经能够很好地压缩这些重复信息。

    1. 我认为,数据库作为存储和检索数据的程序,与作为强制业务规则和领域建模的程序之间存在矛盾。

      我坚定地站在前者阵营。在我看来,数据库的本质应该是尽可能快速高效地存储和检索数据。但数据库领域的共识似乎认为,数据库的主要作用是通过外键约束、触发器、视图、事务、类型安全、领域关系建模等手段来强制执行业务规则和领域模型——其中部分功能与高效存储和检索数据的目标相悖。

      1. 这有道理。也许在组织中/某些人的思维模式中,将数据库的更改设置为受限是更容易的,因为它与代码分离,在许多组织中是标准化的,而且我认为更难更改。

  33. 一篇很棒的文章。很多非常标准的实践。或者至少……应该如此。

    我经常添加的一点是与系统交互的人。他们也是系统的一部分。大多数人并不生活在原子一致的世界中;许多业务流程最终是一致的。但你确实需要知道在哪里必须进行原子操作!这取决于用户期望在哪里进行。

    系统思维非常有用。从你的软件如何部署到使用它的人如何在工作中使用它。始终思考这些事情。

  34. 一篇描述使用现成组件实现的(Web)后端系统架构的模板框架及技术的好文章。但就像我遇到的每一位系统架构师一样,它甚至没有考虑过安全或数据治理(先验地)。

  35. 我们中有多少人会陷入过度设计的陷阱,这一点往往被低估。我也有过这样的经历。就在前几天,有同事建议我们的初创公司不要使用云存储,因为它不可靠,我们应该自己构建。

    也许用简单的东西进行设计并使其可扩展和构建更难。也许我漏掉了什么。不过,基于我十年的经验,我同意作者的观点。

  36. > 索引的工作原理类似于嵌套字典

    如果作者所说的“字典”是指哈希表,那并不完全准确。在关系型数据库中,索引通常是B树,而B树是有序的,与哈希表不同。B树不仅能用于等值搜索,还能用于范围搜索、ORDER BY操作甚至合并连接。

  37. 实际上,事件溯源解决了大多数痛点——事件、模式、推送/拉取、缓存、分布式……等等。缺点是它绝对不适合小型项目,且开销相当大(尤其是在开发阶段,当你希望尽快发布产品时)。另一方面,一旦你让它运行起来,它就是一头不可阻挡的猛兽。

    1. 有一些工具试图解决这个问题(例如MartenDb),但我希望有一种更简单的方式来集成一个系统,其中部分使用ES而部分不使用。

      我看到的几乎所有工具要么是完全基于事件溯源的,要么与事件溯源无关。中间地带的选择并不多。

      1. ES是系统核心,因此这里不存在中间选项。我曾构建过两个生产环境的ES系统,并尝试过一些想法,希望通过将JSON作为任何实体/对象的核心,并使用JSON补丁处理事件来提升开发体验,但最终发现这毫无意义。因为ES必须对模式和数据类型绝对严格,而这些会随时间演变。你必须能够处理可能已有十年历史的事件,以及不再存在的对象。这里没有回旋余地。因此,前述的开销是不可避免的。我并不了解MartenDb,但本质上,当今每个数据库都使用ES,因为这就是事务的工作方式,只不过事件日志在提交后会被丢弃。但无论如何,ES在数据库层面上是没有意义的,除非将其用作实际的审计日志,但你无法以任何方式处理它,因为模式会随时间变化,而数据库模式与应用程序本身几乎无关。

  38. > 我经常对此感到困惑。工程师们看到许多有趣部分组成的复杂系统,就会想:“哇,这里有很多系统设计!”

    每当我读到这样的内容时,都会感到非常困惑。他们根本不知道自己在说些什么,却自称是工程师。无知的自信是一种毫无用处的性格特征。

    1. 黑客可以比正式培训的工程师更快地为飞机增加重量。

      只要我们继续测量代码行数或新增功能,他们就会一直有工作可做。

    2. 除非这种行为受到现代技术面试文化的鼓励,而这种文化确实部分鼓励了它。

  39. > 但在大多数情况下,复制延迟可以通过简单的技巧来绕过:例如,当你更新一条记录但需要立即使用它时,可以在内存中填入更新的详细信息,而不是在写入后立即重新读取。

    我明白,但这听起来像是需要精心调试的代码,而且很容易引发难以排查的 bug。

    1. 两点说明:

      1. 除了 MySQL 之外,几乎所有关系型数据库管理系统(RDBMS)都支持 RETURNING 子句,因此你可以几乎免费地读取写入的数据(甚至 MySQL 也允许你读取其插入的自动递增值)。

      2. 如果不考虑这一点,为什么说这段代码很难写对?如果你从数据库收到了确认,假设你没有对fsync设置做傻事,那么数据已经写入了。它是持久的。因此,在同一个try/except块或类似结构中,使用你刚刚告诉它写入的数据。如果你没有收到确认,那么数据没有写入,所以不要使用你之前存储的数据。

      1. 你必须访问同一实例进行下一次请求。更简单的方法是告诉客户端,他们会收到一个应附加到请求中的标头,以便后端在复制完成前将读取请求路由到主节点。

  40. 这里有很多评论抱怨不必要的复杂性和围绕这个主题的令人沮丧的面试现实。

    我一直在想,投资者为什么容忍这些成本:令人惊讶的是,用简单的方法和一个小而专注的团队可以完成多少事情。

    这一定与他们准备出售公司时的认知有关。

  41. 学习这些内容的最佳实践资源是什么?除了“自己尝试”之外?我是一名其他领域的资深开发者,想转行做后端开发,但不出所料,我没有太多时间去学习完全陌生的领域,

    1. 市面上有一些书籍,比如Tanenbaum的《分布式系统》。还有各种公司出版物,比如谷歌的《SRE手册》和工程博客。

      你也可以参与开源项目,比如Kubernetes或Postgres,以积累经验。

      就像所有事情一样,最好的提升方式就是实践。

  42. 这正是用户和工程师之间良好与糟糕体验的区别所在。一个设计良好的系统既易于使用,又易于维护或改进。它看起来简单,但实际上并不简单。这是领导力和工艺的巅峰。

  43. > 如果你把布尔值存储在数据库中,那你就是个糟糕的工程师

    任何类似的观点都可轻易被视为无稽之谈。遗憾的是,此类标题恰恰能吸引足够的点击量,从而鼓励更多同类胡说八道。

  44. 当系统设计指的是对系统的一般设计,而不仅仅是计算机服务设计时,你称之为何?

    例如:

    – 制定宪法

    – 设计良好的用户体验(DX)的API

    – 改善企业文化

    我直觉上想把所有这些都称为系统设计,因为它们在字面意义上都是系统。但似乎其他人使用“系统设计”一词时,特指分布式计算机服务设计。

    有没有什么词或短语可以用来表示“将系统思维应用于包含人类的系统”?

  45. 这篇文章有一些好的概念,但我觉得它并不能帮助你设计好的系统。它列举了各种选项和基本元素,但良好的设计在于何时以及如何应用它们,而这篇文章并未提供这些内容。

    1. 但这种类型的建议难道不是我们能获得的最佳建议吗?在阅读了《设计数据密集型应用程序》(DDIA)以及一些专注于系统设计面试的书籍(如Alex Xu的著作)后,我注意到两种类型的资源:

      * 有关分布式系统的基础书籍/课程,这些资源能帮助你理解大多数分布式系统和算法的内部工作原理(DDIA 属于此类,尽管它并非最理论化的阐述)

      * 那些倾向于过度简化的手册式指南,(我在此故意夸张)教导人们以“假设有一亿用户,那就用 Cassandra”这样的方式进行推理

      我喜欢这篇文章,因为它专注于实际系统和合理的经验法则,而不是对闲聊协议的又一次重新表述,而这种协议在实践中很少有工程师需要亲自应用。

  46. 不要写关于良好系统设计的文章。

    说真的,这是一个极其微妙和复杂的领域,而且很少有规则。

    例如,“如果你需要来自多个表的数据,可以使用 JOIN 操作而不是分别执行查询并在内存中将它们拼接在一起”,这种方法在某些情况下可能很有用。对于高度可扩展的消费类系统,“尽可能避免使用 JOIN”这一规则往往能取得更好的效果。

    此外,文中未提及理解业务的重要性——包括使用模式、客户需求、数据特性、数据规模、使用规模、安全要求、可用性与可靠性需求、报告要求等。

    1. 而我们所说的“系统”主要指“交易型网站”。

      1. 这就是关键所在。你需要缩小范围才能让这样的内容有用。撰写一篇关于“良好交通设计”的论文是没有意义的。你是指汽车、卡车、船只、飞机、航天器、摩托车、战斗机、坦克吗?你是指能够容纳某些子集的道路吗?

        如果你指的是“交易型网站”,并且假设你指的是产品目录和能够购买的功能,那么范围就缩小了很多。

        还是说没有?

        对于大多数用例来说,Craigslist、eBay、亚马逊是最合适的。

        接下来是用例数量较多的Wix/Square等平台,你可以在其中设计自己的UI。

        然后是基于Python/Ruby等语言的UI/ORM一体化系统,你需要设计自己的数据库 schema 和 UI,但“设计”部分已经为你完成。

        下一步是文章中提到的定制设计系统,因为现成的解决方案不适合。

        然后是高度可扩展的系统。

        如果我们讨论的是定制设计而非最高可扩展性,这篇文章完全没问题。

  47. 精彩的文章。沿着这个思路,有没有书籍、文章或其他媒体可以让我们进一步学习这些原则?

  48. 设计数据密集型应用程序(与原版无冲突,但这篇显然更实用)

  49. 很多好的建议,20年前我本可以使用。但就像所有好的建议一样,我当时会忽略它。

  50. 似乎没有人再欣赏良好的架构/系统设计了。没有人关心。

    领导层无法分辨好坏。如果有什么不同,糟糕的设计似乎更令人印象深刻。工程师往往喜欢糟糕的设计,因为它能带来更多工作和就业保障。当工作量很大时,似乎在取得进展。管理者喜欢糟糕的设计,因为它有助于权力扩张,现在工作量增加,我们需要招聘更多人并做更多管理层的事情。劳动力中也有很多缺乏经验的工程师,他们从未见过设计良好的系统。

    在一个运行这些糟糕设计系统的组织中,质疑设计质量等同于政治自杀。如果业务成功,情况更糟,因为 everyone 都会认为成功业务意味着良好的软件设计。成功业务会直接奖励糟糕设计。

  51. 似乎偏向于网站,而网站大多是简单的 CRUD 操作。

  52. 最近这里有一篇文章讨论如何撰写良好的设计文档:其核心要点是,你的设计文档应让设计显得显而易见。我认为这里得出的结论相同——良好的设计是简单、直截了当的设计,没有真正的意外。

    完全同意。

  53. 有人能推荐一本关于系统设计的书籍吗?

  54. 这不过是披着建议外衣的胡言乱语。“添加索引……但不要添加太多”就是典型例子。它100%正确……但也是100%没人能据此改变行为的建议……这意味着它也是100%毫无价值的建议。

    1. 一个聪明的读者可能会读到这条建议,意识到自己并不真正了解索引是什么,也不知道如何使用它们,然后进行一些研究,学习所需的知识,以便能够利用索引来获得优势。

      如果详细说明一切,这将是一篇极其冗长的文章。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

你也许感兴趣的: