Skip to content
Go back

系统设计入门:每个系统都要做的 7 个决策

Anton Martyniuk 在 2026 年 8 月发表了《Getting Started with System Design: 7 Key Decisions》。他在 .NET 生态里构建和扩展过不少系统,其中一些选择做得很糟。这篇文章是他「希望有人一开始就塞给我」的浓缩版:系统设计不是背组件,而是做决策——每个真实系统都是一连串决策和权衡的结果,而每个决策都要付代价。

选强一致,就付延迟的代价;选消息队列,就付跨服务调试的代价;选微服务,就付运维成本的代价。大多数开发者学系统设计的姿势是错的:他们在背负载均衡、CAP 定理、Kafka 和 Saga——这些 AI 都能讲清楚,但知道组件不等于会设计系统。

本文带你过一遍 7 个每个系统都必须回答的问题:

  1. 这份数据要多新鲜?
  2. 这份数据应该住在哪里?
  3. 买更大的机器,还是买更多机器?
  4. 缓存什么,缓存放在哪?
  5. 这是一个调用,还是一个事件?
  6. 来的活儿比能处理的还多,怎么办?
  7. 你打算部署多少个东西?

读完你会得到一套可以照着问自己的决策框架,以及每个问题背后的权衡和常见坑。

决策 1:这份数据要多新鲜

大多数团队对整个系统只回答一次「新鲜度」,然后沿用好几年。这是错误。新鲜度是按数据类型的决策:支付余额和商品评论数不需要同样的保证。把两者一视同仁,要么给评论数付了过高的钱,要么没保护好钱。

实际可选的模型大概有这几种:

模型你得到的承诺典型用途
强一致(线性化)任意节点上的每次读都看到最新的已提交写入银行余额、库存数量、选主
读己之写用户总能读到自己最近一次写入自己的帖子出现在自己的信息流
单调读一旦看到某个值,就不会再看到更旧的值新闻信息流,回退看起来像坏掉
因果一致如果 A 导致 B,每个读者都先看到 A 再看到 B评论线程、聊天消息
最终一致没有新写入时,所有副本最终收敛分析计数器、评论总数

然后是大部分文章讲错的部分。CAP 定理通常被表述为「分布式系统只能在一致性、可用性、分区容忍中选两个」。这个「三选二」框架正是 Eric Brewer 本人在 2012 年收回的说法:分区容忍不是可选项,因为网络会失败,不容忍分区的系统直接瘫掉。

所以真正的选择是二元的,而且只在分区期间生效:要么继续接受写入、冒险产生分歧(AP),要么拒绝写入、保护正确性(CP)。「CA 数据库」在任何有意义的意义上都不存在。

一个更诚实的日常框架是 PACELC:

网络健康时(通常如此),你每次写入都在拿一致性和延迟做交易:同步 quorum 在区域内大约 1-5 ms,跨区域 50-200 ms;美国东海岸到欧洲的往返就要 80-100 ms,还没算上你的代码。CAP 只在出事时出现,PACELC 是你每天在付的税。

真要给某份数据选边时,回答这四个问题:

  1. 陈旧数据的代价是什么? 过期的余额会导致坏决策;过期的点赞数什么也不会发生。
  2. 宕机的代价是什么? 如果 30 秒不可用就损失几千销售额,倾向 AP。
  3. 以后能对账吗? 合并两个购物车很容易,合并两笔冲突的银行转账不是。
  4. 分区会持续多久? 短分区倾向 CP,跨区域的长时间分区倾向 AP。

大多数生产系统两者都用:支付、订单、鉴权令牌走 CP;目录、推荐、行为追踪走 AP。一旦你知道了每份数据要多新鲜,候选数据库清单会立刻变短。

决策 2:这份数据应该住在哪里

SQL 对 NoSQL 的争论通常被包装成规模问题——它不是。它问的是数据的形状和你查询它的方式

方面SQL(关系型)NoSQL
数据形状固定 schema 的表、行文档、键值、列族或图
连接一等公民,容易有限,或在应用层做
最佳读模式即席报表、复杂查询按键查找、可预测的模式
最佳写模式稳定、低到中吞吐极高吞吐
例子SQL Server、PostgreSQL、MySQLMongoDB、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。

半年后真正会伤到你的不是基准测试,而是:

决策 3:买更大的机器,还是买更多机器

垂直扩展是一台更大的机器;水平扩展是负载均衡后面挂更多机器。

垂直扩展是「无聊但正确」的默认,而且比人们承认的能撑更久。现在单台 VM 轻松给你约 100-200 个通用 vCPU,真正的天花板很少是 CPU 或内存,而是成本和爆炸半径:per-vCPU 定价过了中档就陡升,大约在 16-32 vCPU 的 SKU 处,十个小型实例会比一个大型实例更便宜,还白送故障容忍。

但水平也不免费:更多机器意味着管理开销、监控账单里更高的指标基数、滚动部署替代服务重启,以及每台机器都要交的按核授权税。

真正决定架构形状的,是决策树在存储层分叉

所以大多数生产系统的形态是:垂直扩展的数据库主库 + 水平扩展的应用层。

主库终于不够用时,你就分片:把数据拆到多个独立数据库,每个持有子集。分片键决定每一行走哪个库,选好的分片键有四个标准,按优先级:

  1. 高基数。 customer_id 有数百万个值,能摊开;country_code 只有 200 个,摊不开。
  2. 分布均匀。 如果 90% 的流量打向 10% 的键,那就是热点(坏分片)。
  3. 查询局部性。 大多数查询应该能在一个分片里答完,否则 scatter-gather 会变成你的主导模式。
  4. 可演进性。created_year 分片,会把当前所有流量压在最新的分片上。

另外:后来想改分片键,意味着要重平衡每一行——可能是一个双写、回填、切换的多月项目。请像对待主键一样慎重对待分片键。

扩展是加机器。更便宜的做法是:从一开始就别问数据库。

决策 4:缓存什么,缓存放在哪

数据的三个属性决定缓存层:

  1. 读频率。 多久被请求一次?
  2. 变频率。 底层值多久变一次?
  3. 过期容忍度。 用户看到一个旧值多久才会造成真问题?

按这三项给数据打分,层自己就选出来了:

对比数据库查询的 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 倍流量」——现实里多少系统(包括大厂)就是这么被搞坏的。做任何架构工作之前,先把数字写下来:

然后决定超过这些数字时由谁来承受疼痛。答案只有三个:

策略生产者感受到什么时候选
削负载(立即 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。一个配错的重试策略,会把一次拒绝变成压垮限流器的雪崩。

还有两件事经常坑人:

前面每个决策都是关于一个系统。最后一个问题是你愿意运行几个系统。

决策 7:你打算部署多少个东西

微服务解决的是组织问题,不一定是性能问题。 这一句话能挡掉这个领域里大部分烂决策。

单体模块化单体微服务
部署一个产物一个产物每服务一个
边界靠约定强制的模块,一个数据库网络隔离,独立数据库
一致性事务事务Saga、最终一致
需要平台团队

「我们以后可能要用到扩展」是坏理由。一个无状态 ASP.NET Core 模块化单体,挂在负载均衡后面跑 10-20 个实例毫无问题。「垂直优先」是给有状态组件的建议,不是给你应用层的。「我们的单体很慢」通常也不是理由——问题一般是烂查询、缺索引,或者本该异步的同步调用。

真正的失败模式是造出一个分布式单体:微服务的全部运维成本,加上零自主性。五个问题判断你是不是有一个:

  1. 很多服务必须一起部署?
  2. 服务之间共享数据库?
  3. 大多数用户请求同步串过三个或更多服务?
  4. 一个服务的 schema 变更要求另一个服务协同变更?
  5. 有一个把一切都拉起来的集成测试套件?

微服务在这些时候才配得上它的成本:多个团队需要按不同节奏部署、系统各部分扩展需求差异巨大、一个组件的故障绝不能波及另一个。其他情况,模块化单体更好:干净的边界、一次部署、进程内调用、出问题时一条堆栈。等你有具体理由了,随时可以把一个模块抽成独立服务。

小结

七个决策的要点收拢:

  1. 新鲜度是按数据类型做的决策。 支付要强一致,评论数不需要。大多数日子里你用一致性换延迟,不是换可用性。
  2. 数据库由数据形状决定,不是规模。 横向 SQL 现在已经是解决过的问题。真正咬你的是恢复时间、可排查性和你能不能招到人。
  3. 垂直扩展是无聊的默认,直到存储层。 无状态层往外扩易如反掌;关系型写主库不行。分片键是少数你很难回头的决策。
  4. 按读频率、变频率、过期容忍度缓存。 各层叠加,每层只看到上一层漏掉的。
  5. 队列不消除耦合,只是把耦合移到 schema。 至少一次投递意味着消费者必须幂等,否则客户被扣两次款。
  6. 设计容量之前先把数字写下来。 然后决定生产者失败、变慢还是等待——自动扩缩还要几分钟才到。
  7. 微服务先解决组织问题。 从模块化单体起步。

这 7 个问题没有一个存在唯一正确答案。正确的选择取决于你的上下文、你的约束,以及出问题时你愿意拿什么去换。把它们当成每次设计前必问自己的清单,比背下一堆组件名有用得多。

Aide Hub 持续分享 AI 助手、开发工具与软件工程实践。如果你也在做系统设计或架构评审,欢迎分享你在这 7 个决策上的取舍。

参考


Tags


Previous

ASP.NET Core API 版本化实战(.NET 10)

Next

从零构建类型安全的 C# 管道