Anton Martyniuk 在 2026 年 8 月发表了《Getting Started with System Design: 7 Key Decisions》。他在 .NET 生态里构建和扩展过不少系统,其中一些选择做得很糟。这篇文章是他「希望有人一开始就塞给我」的浓缩版:系统设计不是背组件,而是做决策——每个真实系统都是一连串决策和权衡的结果,而每个决策都要付代价。
选强一致,就付延迟的代价;选消息队列,就付跨服务调试的代价;选微服务,就付运维成本的代价。大多数开发者学系统设计的姿势是错的:他们在背负载均衡、CAP 定理、Kafka 和 Saga——这些 AI 都能讲清楚,但知道组件不等于会设计系统。
本文带你过一遍 7 个每个系统都必须回答的问题:
- 这份数据要多新鲜?
- 这份数据应该住在哪里?
- 买更大的机器,还是买更多机器?
- 缓存什么,缓存放在哪?
- 这是一个调用,还是一个事件?
- 来的活儿比能处理的还多,怎么办?
- 你打算部署多少个东西?
读完你会得到一套可以照着问自己的决策框架,以及每个问题背后的权衡和常见坑。
决策 1:这份数据要多新鲜
大多数团队对整个系统只回答一次「新鲜度」,然后沿用好几年。这是错误。新鲜度是按数据类型的决策:支付余额和商品评论数不需要同样的保证。把两者一视同仁,要么给评论数付了过高的钱,要么没保护好钱。
实际可选的模型大概有这几种:
| 模型 | 你得到的承诺 | 典型用途 |
|---|---|---|
| 强一致(线性化) | 任意节点上的每次读都看到最新的已提交写入 | 银行余额、库存数量、选主 |
| 读己之写 | 用户总能读到自己最近一次写入 | 自己的帖子出现在自己的信息流 |
| 单调读 | 一旦看到某个值,就不会再看到更旧的值 | 新闻信息流,回退看起来像坏掉 |
| 因果一致 | 如果 A 导致 B,每个读者都先看到 A 再看到 B | 评论线程、聊天消息 |
| 最终一致 | 没有新写入时,所有副本最终收敛 | 分析计数器、评论总数 |
然后是大部分文章讲错的部分。CAP 定理通常被表述为「分布式系统只能在一致性、可用性、分区容忍中选两个」。这个「三选二」框架正是 Eric Brewer 本人在 2012 年收回的说法:分区容忍不是可选项,因为网络会失败,不容忍分区的系统直接瘫掉。
所以真正的选择是二元的,而且只在分区期间生效:要么继续接受写入、冒险产生分歧(AP),要么拒绝写入、保护正确性(CP)。「CA 数据库」在任何有意义的意义上都不存在。
一个更诚实的日常框架是 PACELC:
- Partition(分区时):在 Availability 和 Consistency 之间选;
- Else(否则):在 Latency 和 Consistency 之间选。
网络健康时(通常如此),你每次写入都在拿一致性和延迟做交易:同步 quorum 在区域内大约 1-5 ms,跨区域 50-200 ms;美国东海岸到欧洲的往返就要 80-100 ms,还没算上你的代码。CAP 只在出事时出现,PACELC 是你每天在付的税。
真要给某份数据选边时,回答这四个问题:
- 陈旧数据的代价是什么? 过期的余额会导致坏决策;过期的点赞数什么也不会发生。
- 宕机的代价是什么? 如果 30 秒不可用就损失几千销售额,倾向 AP。
- 以后能对账吗? 合并两个购物车很容易,合并两笔冲突的银行转账不是。
- 分区会持续多久? 短分区倾向 CP,跨区域的长时间分区倾向 AP。
大多数生产系统两者都用:支付、订单、鉴权令牌走 CP;目录、推荐、行为追踪走 AP。一旦你知道了每份数据要多新鲜,候选数据库清单会立刻变短。
决策 2:这份数据应该住在哪里
SQL 对 NoSQL 的争论通常被包装成规模问题——它不是。它问的是数据的形状和你查询它的方式。
| 方面 | SQL(关系型) | NoSQL |
|---|---|---|
| 数据形状 | 固定 schema 的表、行 | 文档、键值、列族或图 |
| 连接 | 一等公民,容易 | 有限,或在应用层做 |
| 最佳读模式 | 即席报表、复杂查询 | 按键查找、可预测的模式 |
| 最佳写模式 | 稳定、低到中吞吐 | 极高吞吐 |
| 例子 | SQL Server、PostgreSQL、MySQL | MongoDB、RavenDB、DynamoDB、Cassandra、Redis |
注意别掉进「SQL 只能靠 NoSQL 才能水平扩展」的旧框架:这个权衡在 2018 年是真的,现在已经过时。PostgreSQL 的 Citus、MySQL 的 Vitess、Aurora Limitless,以及 CockroachDB、Spanner、YugabyteDB 这类分布式 SQL 引擎,都能在保留 ACID 事务的同时给你水平写扩展——代价是运维复杂度和钱。
什么时候找 NoSQL:schema 真的按记录变化、数据天然嵌套、或者你需要按已知 key 每秒几百万次写入。什么时候留在 SQL:需要连接、即席查询、跨多表事务(虽然 MongoDB、RavenDB 也支持事务)。大多数 .NET 系统最终两种都用:核心事务数据放 SQL Server 或 PostgreSQL + EF Core,高量或灵活数据放 Cosmos DB 或 MongoDB。
半年后真正会伤到你的不是基准测试,而是:
- 一次恢复要多久,而且你测过恢复吗?RDS 和 Azure SQL 可以在保留期内按时间点恢复,DynamoDB 提供最长 35 天的 point-in-time recovery,Redis 只有快照、根本没有 PITR。每季度测一次恢复——没恢复过的备份不算备份。
- 生产里找出一个慢查询、修好一个坏查询要多久?
- 你能招到懂这个引擎的人吗?
决策 3:买更大的机器,还是买更多机器
垂直扩展是一台更大的机器;水平扩展是负载均衡后面挂更多机器。
垂直扩展是「无聊但正确」的默认,而且比人们承认的能撑更久。现在单台 VM 轻松给你约 100-200 个通用 vCPU,真正的天花板很少是 CPU 或内存,而是成本和爆炸半径:per-vCPU 定价过了中档就陡升,大约在 16-32 vCPU 的 SKU 处,十个小型实例会比一个大型实例更便宜,还白送故障容忍。
但水平也不免费:更多机器意味着管理开销、监控账单里更高的指标基数、滚动部署替代服务重启,以及每台机器都要交的按核授权税。
真正决定架构形状的,是决策树在存储层分叉:
- 无状态应用服务器扩展出去易如反掌——加一台机器,加进负载均衡目标组,完事。
- 关系型数据库主库不行:只有一个 writer,这个 writer 的 CPU 和 IO 就是写吞吐的天花板。读副本扩展读;写只能垂直扩展,直到你commit 到分片或分布式 SQL。
所以大多数生产系统的形态是:垂直扩展的数据库主库 + 水平扩展的应用层。
主库终于不够用时,你就分片:把数据拆到多个独立数据库,每个持有子集。分片键决定每一行走哪个库,选好的分片键有四个标准,按优先级:
- 高基数。
customer_id有数百万个值,能摊开;country_code只有 200 个,摊不开。 - 分布均匀。 如果 90% 的流量打向 10% 的键,那就是热点(坏分片)。
- 查询局部性。 大多数查询应该能在一个分片里答完,否则 scatter-gather 会变成你的主导模式。
- 可演进性。 按
created_year分片,会把当前所有流量压在最新的分片上。
另外:后来想改分片键,意味着要重平衡每一行——可能是一个双写、回填、切换的多月项目。请像对待主键一样慎重对待分片键。
扩展是加机器。更便宜的做法是:从一开始就别问数据库。
决策 4:缓存什么,缓存放在哪
数据的三个属性决定缓存层:
- 读频率。 多久被请求一次?
- 变频率。 底层值多久变一次?
- 过期容忍度。 用户看到一个旧值多久才会造成真问题?
按这三项给数据打分,层自己就选出来了:
- L1 进程内内存: 微秒级,每台服务器一份。放每秒读几千次、很少变的:功能开关、货币表、角色定义。TTL 保持 1-2 分钟。
- L2 分布式缓存(Redis): 约 1 ms,所有服务器共享。放读得频繁、跨服务器共享、偶尔变的:用户资料、目录、昂贵的查询结果。所有节点看到同一份值,10-30 分钟 TTL 合适。
- L3 CDN 边缘: 约 10 ms,全球。放对许多用户相同的:静态资源、公共 API 响应、预渲染页面。它完全不碰你的服务器。
对比数据库查询的 10-100 ms,为什么要叠着用显而易见。
两个坑:别缓存一次性 token,也别缓存任何「变得比读得还快」的东西。还有一个可靠的层配置错误信号:本地 TTL 比 Redis TTL 还长,意味着你的服务器在提供共享缓存已经扔掉了的数据。
缓存让读变便宜,对写毫无帮助——而写才是组件开始互相等待的地方。
决策 5:这是一个调用,还是一个事件
规则一句话:调用方现在就需要答案,就做直接调用;只需要活儿干完,就发事件。加载商品页需要现在有答案,扣款需要现在有答案;发确认邮件、更新搜索索引、通知仓库都不需要。
| 方面 | 直接 API 调用 | 消息队列 |
|---|---|---|
| 发送方等待响应? | 是 | 否 |
| 接收方宕机 | 整个调用失败 | 消息在队列里等着 |
| 流量尖峰 | 接收方必须扛住峰值 | 队列吸收突发 |
| 延迟 | 正常路径更低 | broker 开销 p50 约 5-50 ms,积压时数秒到数分钟 |
| 失败处理 | 调用方实现重试 | broker 重投,你负责退避和死信 |
人们低估的是账单。队列不消除耦合,只是把耦合从 URL 挪到了 schema。 给事件加一个必填字段,旧消费者就挂了;重命名一个,所有人一起挂。事件版本化要从第一天就规划:建 schema registry,或显式加 schemaVersion 字段。
broker 本身也是新的单点故障,有自己的集群、要监控的死信队列、要雇佣的 on-call 专家。而且几乎每个 broker 都是至少一次投递,你的消费者迟早会看到同一条消息两次——它必须幂等,否则你会给客户扣两次款。
最便宜的防御是数据库里的唯一约束:
public sealed class Order
{
public Guid Id { get; init; }
public required string IdempotencyKey { get; init; }
public decimal Total { get; init; }
}
builder.Entity<Order>()
.HasIndex(o => o.IdempotencyKey)
.IsUnique();
同一个 key 第二次插入会抛异常——这正是想要的。捕获唯一键冲突,把这条消息当已处理,然后确认它。不需要中间件,不需要分布式锁。
选 broker 就看三个问题:需要回放历史?Kafka。需要复杂路由规则?RabbitMQ。想要云内零运维?SQS 或 Azure Service Bus。
队列吸收突发,但它不创造容量。那么问题来了:突发比缓冲还大怎么办?
决策 6:来的活儿比能处理的多,怎么办
「我们要扛 10 倍流量」——现实里多少系统(包括大厂)就是这么被搞坏的。做任何架构工作之前,先把数字写下来:
- 基线每秒请求数,写在真正重要的那一层:「目录服务 400 持续、800 峰值」。
- 峰值目标。峰值的 10 倍就是每秒 8000 请求。
- 每个端点的读写拆分。两者的扩展方式差别巨大。
- 数字化的延迟目标。浏览 p99 < 200 ms,结账 < 1000 ms。
- 库存和错误预算。只有 5000 件货,最多只能卖 5000 单,所以「售罄」路径也要设计。「5% 错误率可接受」是站得住的姿态,「0% 错误」不是。
然后决定超过这些数字时由谁来承受疼痛。答案只有三个:
| 策略 | 生产者感受到 | 什么时候选 |
|---|---|---|
| 削负载(立即 429/503) | 「被拒绝了,稍后再试」 | 延迟就没价值的活,或真的追不上 |
| 限流(按固定速率准入) | 「这要更久」 | 以后能追上,且租户间公平很重要 |
| 排队(有界缓冲) | 「这等着呢」 | 短而可预测、能装进小缓冲的突发 |
三者都有效。架构问题是你偏好谁的痛苦:生产者失败、生产者变慢,还是生产者等待。不选择等于三种最坏的都吃到。
在 .NET 里,一个原语就能拿到全部三种:
builder.Services.AddRateLimiter(options =>
{
options.AddConcurrencyLimiter("checkout", limiter =>
{
limiter.PermitLimit = 10;
limiter.QueueLimit = 20;
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
PermitLimit 是限流,QueueLimit 是有界缓冲,超出部分自动以 503 削掉。三个配置值,三种策略。
另外:429 或 503 一定要带 Retry-After,重试次数封顶两到三次,指数退避加 jitter。一个配错的重试策略,会把一次拒绝变成压垮限流器的雪崩。
还有两件事经常坑人:
- 自动扩缩来得很晚。 指标采集要 10-60 秒,扩缩决策再要 30-60 秒,供给和预热还要 60-300 秒——从「负载来了」到「新实例开始服务」一共 2-7 分钟,而这恰恰是成交发生的时间窗。提前在活动开始前 10-15 分钟预扩到峰值,让自动扩缩在之上再加余量。
- 扩应用层会压垮数据库。 50 个实例各开 100 个连接就是 5000 个连接,对着 PostgreSQL 默认的 100。保持
app_instances * max_pool_per_instance <= db_max_connections * 0.8,或者前面放 PgBouncer、RDS Proxy 这类连接池。
前面每个决策都是关于一个系统。最后一个问题是你愿意运行几个系统。
决策 7:你打算部署多少个东西
微服务解决的是组织问题,不一定是性能问题。 这一句话能挡掉这个领域里大部分烂决策。
| 单体 | 模块化单体 | 微服务 | |
|---|---|---|---|
| 部署 | 一个产物 | 一个产物 | 每服务一个 |
| 边界 | 靠约定 | 强制的模块,一个数据库 | 网络隔离,独立数据库 |
| 一致性 | 事务 | 事务 | Saga、最终一致 |
| 需要平台团队 | 否 | 否 | 是 |
「我们以后可能要用到扩展」是坏理由。一个无状态 ASP.NET Core 模块化单体,挂在负载均衡后面跑 10-20 个实例毫无问题。「垂直优先」是给有状态组件的建议,不是给你应用层的。「我们的单体很慢」通常也不是理由——问题一般是烂查询、缺索引,或者本该异步的同步调用。
真正的失败模式是造出一个分布式单体:微服务的全部运维成本,加上零自主性。五个问题判断你是不是有一个:
- 很多服务必须一起部署?
- 服务之间共享数据库?
- 大多数用户请求同步串过三个或更多服务?
- 一个服务的 schema 变更要求另一个服务协同变更?
- 有一个把一切都拉起来的集成测试套件?
微服务在这些时候才配得上它的成本:多个团队需要按不同节奏部署、系统各部分扩展需求差异巨大、一个组件的故障绝不能波及另一个。其他情况,模块化单体更好:干净的边界、一次部署、进程内调用、出问题时一条堆栈。等你有具体理由了,随时可以把一个模块抽成独立服务。
小结
七个决策的要点收拢:
- 新鲜度是按数据类型做的决策。 支付要强一致,评论数不需要。大多数日子里你用一致性换延迟,不是换可用性。
- 数据库由数据形状决定,不是规模。 横向 SQL 现在已经是解决过的问题。真正咬你的是恢复时间、可排查性和你能不能招到人。
- 垂直扩展是无聊的默认,直到存储层。 无状态层往外扩易如反掌;关系型写主库不行。分片键是少数你很难回头的决策。
- 按读频率、变频率、过期容忍度缓存。 各层叠加,每层只看到上一层漏掉的。
- 队列不消除耦合,只是把耦合移到 schema。 至少一次投递意味着消费者必须幂等,否则客户被扣两次款。
- 设计容量之前先把数字写下来。 然后决定生产者失败、变慢还是等待——自动扩缩还要几分钟才到。
- 微服务先解决组织问题。 从模块化单体起步。
这 7 个问题没有一个存在唯一正确答案。正确的选择取决于你的上下文、你的约束,以及出问题时你愿意拿什么去换。把它们当成每次设计前必问自己的清单,比背下一堆组件名有用得多。
Aide Hub 持续分享 AI 助手、开发工具与软件工程实践。如果你也在做系统设计或架构评审,欢迎分享你在这 7 个决策上的取舍。
参考
- Getting Started with System Design: 7 Key Decisions(原文,Anton Martyniuk)
- Building a Modular Monolith with Vertical Slice Architecture in .NET(模块化单体)
- Migrating Modular Monolith to Microservices in .NET(模块抽离)
- CAP theorem | Wikipedia
- PACELC theorem | Wikipedia
- Rate limiting middleware in ASP.NET Core | Microsoft Learn