Skip to content
Go back

系统设计 20 个积木:顺序比清单重要

一个上线两年的订单系统开始变慢。团队的第一反应是加缓存,第二反应是把订单表拆成四个库。但如果真正的原因是主库的写入打满了,这两个动作一个不减少写入、一个只把写入换个地方排队,都不算解决问题。

原文是 Anurag 写的《System Design Essentials - 1》,用 20 个编号、21 个小节把系统设计的基础概念过了一遍(第 7 号出现了两次)。写法很整齐:每个概念一段解释、一张图、它解决什么问题、代价是什么。

这份清单本身没问题,问题是清单给不出顺序。知道有复制、分片、缓存这几样东西,和知道此刻该动哪一个,是两件不同的事。下面按「压力从哪里来」把同样的内容重排一遍,并在原文有误的地方标出正确的说法。

先看清压力压在哪一层

Client-Server 是所有系统的起点:客户端发请求,服务端处理,服务端再去读写数据库。客户端直连数据库叫 2-tier;中间加一层应用服务器叫 3-tier,也就是今天绝大多数应用的样子。API Gateway 是同一个模型的延伸——客户端只看见一个入口,后面的服务对它不可见。

这一层里真正决定后续所有选择可能性的,是无状态。HTTP 本身是无状态协议,每个请求都要带齐服务端需要的信息。无状态不是风格问题,而是水平扩展的前提:只有任何一台机器都能处理任何一个请求,加机器才有意义。一旦把会话、购物车、上传中的文件放进应用进程的内存里,扩展就从「加机器」变成了「必须把同一个用户粘到同一台机器上」。

原文把 HTTP 排在第 10 位讲,但它其实是最前面的前提。

垂直还是水平:第一个岔路

垂直扩展是给现有机器加内存、加 CPU、加存储。原文对它的判断很准:初期很好用,很快撞到天花板,硬件有上限。水平扩展是加机器,原文叫它 fan-out 模式,多台机器一起完成同一件事。

原文没说的是,垂直扩展真正的代价不只是硬件上限。加内存、换 CPU 通常要重启,所以它带停机窗口;而且那台机器仍然是唯一的机器,单点问题一点没缓解。垂直扩展是买时间,不是买结构。 用它撑过这一轮增长没问题,但要清楚它不改变架构性质。

复制、分区、分片各只动一个变量

这是最容易混淆、也是原文最有价值的一段。三个词经常被当成同一件事,实际上它们各自只改变一个变量:

手段改变什么解决什么解决不了什么主要代价
垂直扩展单机规格单机吞吐上限(短期)单点、硬件上限停机窗口、成本曲线陡
复制数据的副本数量读吞吐、读可用性写吞吐、单库数据总量副本延迟、写放大、故障切换
分区单个库内部的组织管理粒度、冷数据归档单机资源上限跨区查询变复杂
分片数据被切到多个库写吞吐、单库数据总量跨片查询与跨片事务路由、再平衡、跨片 join
缓存重复读的命中位置重复的读写压力、一致性失效策略、脏读窗口

复制这条值得单独记住:主从复制里每一份副本都存着全量数据,写仍然只能走主库。 所以复制扩的是读,不是写。原文这句话写得很清楚,也解释了为什么「读多写少」的应用加副本立竿见影,而写入打满时加副本毫无帮助。副本带来的另一个好处原文没展开:主库挂掉时副本可以顶上,读可用性同时也意味着故障切换的余地。

同步复制要等副本确认才返回调用方,写延迟变高,但故障切换时数据更完整;异步复制立刻返回、后台复制,写更快,但主库在复制完成前崩掉就会丢数据。判断依据只有一个问题:你能接受丢多少秒的写入?这个数字(RPO)应该在选复制模式之前就写出来,而不是等出事之后再讨论。

分片需要一把 sharding key,原文列了范围、哈希、地理三种策略。范围分片便于范围查询,但如果 key 是自增 id 或时间戳,新数据会全部压在最后一个分片上,热点就这么来的;哈希分片分布均匀,代价是范围查询要打到所有分片。原文列了策略,没提热点是怎么产生的——选 key 时这一步比记策略名重要得多。

一致性:能接受多晚,而不是要不要

原文在这里有一处必须纠正。它把 ACID 展开成 Atomicity、Consistency、Isolation、Fault Tolerance,并把这一条排在第四位。ACID 的 D 是 Durability(持久性),指的是事务提交后即使系统立刻崩溃,数据依然保存下来。原文对这条性质的描述本身完全正确,只是缩写的展开写串了。

原子性用转账最好解释:A 转账给 B 分两步,先从 A 扣款,再给 B 入账。如果第二步失败,第一步必须回滚,事务要么整体完成,要么完全不发生。隔离性指并发事务互不干扰;一致性指事务只把数据库从一个合法状态带到另一个合法状态。

BASE 是另一套取向——Basically Available(基本可用)、Soft State(软状态)、Eventually Consistent(最终一致)。它优先保证可用性,允许数据在一段时间内不一致,等特点同步完成。

CAP 常被讲成「三选二」,原文也这么写:CA 系统选择一致性和可用性、放弃分区容忍。这个说法在分布式系统里站不住。 网络分区不是设计选项,是网络迟早会发生的事实,你没法「放弃」它。Brewer 在 CAP 提出十二年后专门写过这件事:把 CAP 当菜单是一种误读。真正成立的说法是——分区没有发生时,一致性和可用性可以同时满足;分区发生时,你才必须在两者之间选一个。所谓 CA 系统,实际指的是不做跨网络复制的单机系统,它从来没有真正面对过 P。

所以更实用的读法是两步:先问这份数据能接受多晚一致,再问分区时是拒绝请求(偏 CP)还是返回可能过期的数据(偏 AP)。原文的总结——ACID 偏 CP、BASE 偏 AP——是对的。

但真实系统很少整体选一边。余额和库存扣减要强一致,点赞数、浏览计数、推荐位可以最终一致。按操作的粒度选,不是按数据库的粒度选,这一条比记住 ACID、BASE 两个缩写有用得多。

数据库:先写出查询,再选存储

关系型数据库把数据存成行列组成的表,schema 严格预定义,表之间用外键表达关系,靠 join 在一次查询里跨表取数,并且遵循 ACID。原文说它适合准确度优先的场景,比如银行和订单管理;举例 PostgreSQL、MySQL、Oracle,没有问题。

非关系型数据库不要求固定表结构,数据以文档、键值、宽列等形式存放,每条记录结构可以不同,因此适合字段变化快的数据,也天然按水平扩展设计,多数走 BASE 而不是 ACID。原文举例 MongoDB、Redis、Cassandra。

原文还列出了图、面向对象、层次、向量四类数据库但没有展开,补一句它们各自的位置:图数据库为多跳关系遍历而生,比如社交关系链和权限推导;向量数据库为相似度检索而生,是 embedding 召回的基础设施;面向对象和层次数据库今天主要存在于遗留系统和特定嵌入式场景里。

原文把「适合非结构化数据」和「需要水平扩展」并列成 NoSQL 的两个理由,实际项目里更常见的驱动是后者。选型的顺序应该是:先写出三类最重要的查询,看它们是否需要跨实体 join 和事务;需要就用关系型。只有当固定 schema 确实装不下快速变化的字段,或者写入量已经超过单个关系库的扩展上限时,NoSQL 的弹性才值回它的代价。

单体还是微服务:边界比大小重要

单体是一个统一代码库,所有功能一起运行:一次构建、一次部署。原文把它分成两种——传统单体把 UI、业务层、数据访问层和数据库都放在同一个仓库里,典型例子是 Java 或 Spring 应用;模块化单体只让服务层保持单体,UI 和数据库分开。优点原文列得准确:搭建、开发、测试、部署都简单,执行快,适合小团队和有限范围。缺点同样准确:规模一大就难维护,层与层紧耦合,多个团队没法独立工作,技术栈被锁死。

微服务把系统拆成多个小服务,每个服务负责一块业务功能,有自己的代码库、部署流水线和常常独立的数据库。原文用电商举例:User Service 管认证和资料,Product Service 管目录和库存,Order Service 管下单和跟踪,Payment Service 管交易。收益是独立部署、独立扩缩、故障隔离、技术选型自由、迭代更快;代价是通信复杂、数据管理、运维开销、测试复杂和延迟。

这份优缺点清单里最容易被低估的是「数据管理」。单体里一个本地事务就能完成的事——下单同时扣库存、记流水——拆开之后变成跨服务的一致性问题,要么引入 saga 和补偿逻辑,要么接受最终一致。所以拆服务的第一步不是画服务图,而是找出哪些操作必须原子完成。 这些操作要么留在同一个服务里,要么你就得专门为它们设计补偿路径,没有第三种可能。

API Gateway 是客户端唯一对接的入口,原文列的职责基本完整:认证授权、路由到正确的微服务、日志与监控、负载均衡、限流、缓存,以及协议转换(入口收 HTTP,服务之间转 gRPC)。这里有个容易混的边界:网关是入口层的逻辑角色,实际部署中负载均衡往往在它前面,也可能由它内部实现。讨论时把「网关负责什么」和「负载均衡器负责什么」分开说,责任会清楚很多。

服务之间怎么说话

REST 是建立在 HTTP 之上的一套 API 设计规则,原文列出六个原则:统一接口、无状态、客户端-服务端分离、可缓存、分层系统、按需代码(Code-On-Demand,服务端把可执行代码下发给客户端扩展功能)。补一句:Fielding 在提出 REST 的论文里把 Code-On-Demand 标为可选约束,一个不满足它的接口仍然可以是 RESTful。把它当成六条硬性要求是常见误读。

SOAP 的消息只走 XML,每次请求和响应都包在 Envelope 里,Header 放元数据,Body 放业务数据和错误详情。原文把 SOAP 展开为 Simple Object Access Protocol,这是 SOAP 1.1 时代的名字。W3C 在 SOAP 1.2 里明确改了口径:SOAP 不再是一个缩写。今天它主要出现在已有企业协议和遗留系统里。

GraphQL 针对 REST 的两个毛病:over-fetching 是取回了不需要的字段,under-fetching 是一次拿不全、要串多次调用。原文的例子很具体:取一个用户资料页,REST 要打两次(GET /users/12GET /users/12/orders?limit=3),每次都返回完整对象,包括你不需要的 email 和 address,最后还要在客户端把两个响应拼起来;GraphQL 只有一个端点 POST /graphql,客户端声明要哪些字段,响应形状与请求精确匹配,一次请求拿完。

原文没提 GraphQL 的代价:它把「每个接口取多少数据」的控制权交给了客户端,于是缓存粒度变细,查询深度和成本上限、以及字段解析时的 N+1 查询,都要在服务端自己控制。对外放开 GraphQL 之前,先设计好单次查询的成本上限。

gRPC 让一台服务器调用另一台机器上的方法,写起来像调用本地函数。它快的原因原文列了两条:用 ProtoBuf 把数据序列化成紧凑的二进制,负载更小、解析更快;跑在 HTTP/2 上,比 REST 常用的 HTTP/1.1 更高效。适用场景是服务间通信、低延迟要求和流式传输。

这里也有一处要纠正:原文把 gRPC 展开成 Google Remote Procedure Call。gRPC 官方仓库里专门有一份文档记录这件事——gRPC 是递归缩写,每个版本里 g 代表不同的词:1.0 是 gRPC,1.1 是 good,1.2 是 green,1.3 是 gentle……所以 Google Remote Procedure Call 是流传很广但非官方的说法。

方式最适合主要代价
REST对外 API,需要被缓存和手工调试over-fetching、under-fetching
GraphQL多端字段需求差异大,一个页面要聚合缓存与查询复杂度得自己控制
gRPC内部服务间、低延迟、双向流浏览器直连要代理、消息不可读
SOAP既有企业协议、遗留系统XML 冗长、工具链重

谁先开口:轮询、WebSocket、Webhook

原文用一个很具体的场景把三者串起来了:电商结算页的扫码支付。用户拿手机扫码付款,页面上要自动更新支付状态,不能指望用户手动刷新。

轮询是客户端每秒问一次 GET /payments/33/status。实现最简单,但绝大多数请求的答案是「还没好」——原文列的两个缺点(浪费资源、可能压垮服务端)都源于此。服务端把请求挂住、有变化或超时再返回的长轮询能把空转降下来,是不能建长连接时的常用折中。

WebSocket 是一条持久的双向连接。它先以普通 HTTP 请求开始,服务端返回 101 Switching Protocols 之后升级,请求-响应模式结束,之后两端可以随时互相推消息,不必重复握手。聊天、在线游戏、行情、外卖进度、Google Docs 这类协同工具都用它。

Webhook 把方向反过来:客户端把自己的回调地址交给服务端,事件发生时由服务端主动 POST 过来。原文这个对照很清楚——REST、GraphQL、gRPC 都是拉取(pull),Webhook 是推送(push)。

原文没提但工程上必须知道的一点:Webhook 的重推是常态,不是异常。 接收端要按事件 id 做幂等,要能容忍重复投递和乱序,要校验签名确认请求真的来自对方,还要让发送方在失败时重试。只写接收逻辑不做幂等,第一次网络抖动就会变成重复发货。

把积木用起来

这 20 个概念不需要同时出现在一个系统里。真要动手时,按这个顺序问下来,每一步都只做一个选择:

  1. 读写比和量级是多少,瓶颈在读写哪一侧
  2. 哪些操作必须原子完成,哪些数据能接受多晚一致
  3. 选存储(关系型还是 NoSQL)和分片键
  4. 划服务边界,默认从模块化单体起步
  5. 定通信方式:对内 gRPC,对外 REST,聚合页考虑 GraphQL
  6. 定实时通知:双向高频用 WebSocket,一次性事件用 Webhook
  7. 最后才是缓存、CDN、限流这些优化层

把缓存放到最后一步是有原因的:缓存和限流是优化,不是修复。 它们能让一个架构正确的系统更快,但不会让一个写路径已经错的系统变对,只会把问题推迟到缓存失效的那一刻。原文把 HTTP 排在第 10、把缓存类话题留到第二部分,顺序上反而容易让人先想到加缓存。

最后是风险边界。20 个积木的组合空间远大于 20,而复杂度是叠加的。同时引入复制、分片、微服务和最终一致的团队,排错时经常连「这条数据应该在哪」都说不清。每加一块积木,都要能说出它对应的是哪一个具体数字问题。

原文结尾说第二部分会讲事件驱动架构、负载均衡算法、限流算法和 Worker 队列——那些是「多台机器怎么协作」的下一层(开头说这是三部分系列的第一篇,结尾写的是第二部分也是最后一篇,前后不一致,等后续更新)。在此之前,如果你正在做扩容决定,先别急着加缓存:把主库写入量、读写比例、必须原子完成的操作这三件事量出来,上面那张表里每一行该不该动,答案基本就出来了。

如果你也在为扩容方案做判断,Aide Hub 会继续写软件工程实践、AI 助手和开发工具的用法,尽量把每个结论的适用边界交代清楚,而不是只给一张概念清单。

参考


Tags


Previous

PG 转 SQL Server:日期时间类型映射与陷阱

Next

RAG 检索授权:租户过滤、溯源与删除