Skip to content

系统设计基础

系统设计是构建能在规模化环境中可靠运行的软件的方法。本文涵盖 client-server 架构、网络协议、DNS、proxy、load balancing、caching、数据库、消息队列、一致性模型以及弹性设计模式

  • 生产环境中的每个 ML 系统本质上都是分布式系统。一个推荐引擎不仅仅是一个 model——它还包括 API server、feature store、model registry、caching 层、消息队列和监控栈,它们通过网络相互通信。理解系统设计,是区分"我训练了一个 model"和"我构建了一个产品"的关键。

  • 顶级科技公司(Google、Meta、Amazon、OpenAI)的系统设计面试会考察你是否具备设计这些系统的能力。本章提供构建块(本文)、cloud 基础设施(第 02 文)、扩展模式(第 03 文)、ML 专项设计(第 04 文)和实战案例(第 05 文)。

Client-Server 架构

  • 基本模式:client 发送请求,server 处理后返回响应。你的浏览器(client)向 google.com(server)发送 HTTP 请求,server 返回 HTML。

  • 请求-响应模型:同步模式。client 等待响应。简单,但存在瓶颈:client 在等待时处于空闲状态,server 必须先完成请求才能继续处理其他任务。

  • 无状态 server:server 不记录之前的请求。每个请求包含处理所需的全部信息。这使扩展变得容易:任意 server 都能处理任意请求,因此可以在 load balancer 后面增加更多 server。

  • 有状态 server:server 在请求之间保持状态(例如用户会话)。更难扩展,因为同一用户的请求必须发往同一 server(会话亲和性)。现代系统通过将状态存储在数据库或 cache(Redis)中来避免 server 端状态。

网络协议

  • 我们在第 13 章介绍了网络基础(TCP/IP 分层、socket)。这里关注系统设计中使用的应用层协议:

  • HTTP/HTTPS:Web 和大多数 API 使用的协议。请求方法:GET(读取)、POST(创建/预测)、PUT(更新)、DELETE(删除)。HTTPS 加入了 TLS 加密(第 13 章安全)。REST API(第 15 章第 03 文)基于 HTTP 构建。

  • WebSocket:client 与 server 之间的持久双向连接。与 HTTP(请求 → 响应 → 连接关闭)不同,WebSocket 保持连接开放,用于实时流式传输。应用场景:LLM token 流式传输(生成 token 时即时发送)、实时仪表板、聊天应用。

  • gRPC:Google 的 RPC 框架。基于 HTTP/2 使用 Protocol Buffers(二进制序列化,比 JSON 小且快约 10 倍)。支持流式传输(server 端、client 端、双向)。用于性能敏感的服务间内部通信。Triton Inference Server(第 15 章)和 TensorFlow Serving 使用 gRPC。

  • Protocol Buffers:在 .proto 文件中定义消息 schema:

message PredictRequest {
    repeated float features = 1;
    string model_version = 2;
}

message PredictResponse {
    float prediction = 1;
    float confidence = 2;
}

service ModelService {
    rpc Predict(PredictRequest) returns (PredictResponse);
}
  • schema 可编译为任意语言(Python、C++、Go、Java)的 client 和 server 代码。类型安全、向后兼容性和性能一步到位。

DNS

  • DNS(Domain Name System)将人类可读的域名转换为 IP 地址(第 13 章)。在系统设计中,DNS 还提供:

  • 基于 DNS 的 load balancing:对同一域名返回不同 IP 地址,将流量分发到多台 server。简单但粒度粗糙(DNS 结果会被缓存数分钟到数小时,因此流量不会快速重新平衡)。

  • 地理路由:根据 client 位置返回最近数据中心的 IP。东京的用户得到日本数据中心;伦敦的用户得到欧洲数据中心。

  • 故障切换:server 宕机后,DNS 停止返回其 IP。新 client 流量转向健康 server。但已缓存的 DNS 条目意味着部分 client 在 TTL 到期前仍会访问故障 server(TTL 问题)。

Proxy

  • proxy 是 client 与 server 之间的中介:

  • 反向 proxy(位于 server 前端):client 连接到 proxy,proxy 将请求转发给后端 server。client 不知道哪台 server 处理了请求。NginxHAProxy 是标准的反向 proxy。它们提供:load balancing(分发请求)、SSL 终止(在 proxy 处解密 HTTPS,向后端发送明文 HTTP)、caching、速率限制和压缩。

  • API gateway:专用于 API 的反向 proxy。处理认证、速率限制、请求路由(不同路径 → 不同服务)和 API 版本管理。KongAWS API GatewayEnvoy 是常见选择。

  • 对于 ML serving:API gateway 位于 model server 前端。它验证 API 密钥、对免费用户限速、将 /v1/predict 路由到 model server A、/v2/predict 路由到 model server B,并收集使用指标。

Load Balancing

  • 当存在多台 server 时,load balancer 将入站请求分发给它们。

Load balancer 将入站请求分发给多台后端 server

  • 算法

    • 轮询(Round robin):按顺序将请求发给各 server(1、2、3、1、2、3……)。简单公平,但不考虑 server 负载。
    • 最少连接(Least connections):发给活跃连接数最少的 server。对处理时间可变的请求更好(有的 LLM 请求生成 10 个 token,有的生成 1000 个)。
    • 加权轮询(Weighted round robin):容量更大的 server 处理更多请求。GPU 显存 80 GB 的 server 处理的请求量是 40 GB server 的 2 倍。
    • 一致性哈希(Consistent hashing):对请求 key 进行哈希,路由到固定 server。相同 key 始终路由到同一 server。适用场景:caching(同一用户的请求命中同一 cache)、会话亲和性以及前缀 caching(第 17 章:拥有相同 system prompt 的请求路由到已持有对应 KV-cache 的 server)。
  • L4 与 L7 load balancing

    • L4(传输层):基于 IP 和端口路由。速度快,但无法检查请求内容。
    • L7(应用层):基于 HTTP 路径、header 或请求体内容路由。可将 /api/chat 路由到聊天 server、/api/embed 路由到 embedding server。速度较慢,但灵活性更高。

Caching

  • Caching 将频繁访问的数据存储在快速存储层(RAM)中,避免重复计算或重复获取。

Cache-aside 模式:先检查 cache,未命中时从数据库获取并存入 cache 以供下次使用

  • Cache 模式

    • Cache-aside(懒加载):应用先检查 cache。未命中时,从数据库获取、存入 cache 并返回。最常见的模式。
    • Write-through:每次写入同时更新 cache 和数据库。确保 cache 始终是最新的,但会降低写入速度。
    • Write-back:写入只进入 cache;cache 异步地将数据刷新到数据库。写入速度最快,但若 cache 在刷新前崩溃则有数据丢失风险。
  • 淘汰策略(cache 满时):

    • LRU(最近最少使用):淘汰最长时间未访问的条目。最常见的策略。
    • LFU(最不常用):淘汰访问次数最少的条目。适用于部分数据持续热门的场景。
    • TTL(生存时间):条目在固定时间后过期。用于会变陈旧的数据(model 预测结果缓存 5 分钟,feature 值缓存 1 小时)。
  • CDN(Content Delivery Network):全球分布的静态内容(图片、JavaScript、CSS)cache。在全球 100+ 个地点部署 server,从距用户最近的地点提供缓存内容。对于 ML:model 权重可在 CDN 上缓存以加速下载。

  • Redis:标准的内存 cache/数据库。支持字符串、列表、集合、有序集合、哈希和流。亚毫秒级延迟。用途:缓存 model 预测结果、存储会话数据、速率限制(按分钟统计每用户请求数)以及实时 feature serving。

  • 对于 ML serving:对重复输入缓存预测结果。若大量用户询问"法国首都是哪里?",只需计算一次答案,后续请求直接返回缓存结果。聊天机器人工作负载的 cache 命中率通常为 20-40%,可按比例降低 GPU 成本。

数据库

SQL(关系型)

  • SQL 数据库(PostgreSQL、MySQL)以表格形式存储数据,通过外键表达表间关系,使用 SQL 查询。ACID 保证:

    • 原子性(Atomicity):事务要么全部完成,要么全部回滚。不存在部分更新。
    • 一致性(Consistency):数据库从一个有效状态迁移到另一个有效状态。约束(唯一键、外键)始终满足。
    • 隔离性(Isolation):并发事务互不干扰。
    • 持久性(Durability):已提交的数据在崩溃后仍然存在(在确认前写入磁盘)。
  • SQL 数据库擅长:具有关系的结构化数据、复杂查询(join、聚合)、严格一致性要求以及数据完整性。

NoSQL

  • NoSQL 数据库以可扩展性和灵活性换取部分 ACID 保证:

    • 键值存储(Redis、DynamoDB):最简单的模型。按 key 快速查找。用于 caching、会话存储和 feature store。
    • 文档存储(MongoDB、Firestore):存储类 JSON 文档。灵活 schema(每个文档可有不同字段)。用于用户档案、产品目录和配置。
    • 列族存储(Cassandra、HBase):针对写密集型和时序数据优化。用于事件日志、指标和分析。
    • 图数据库(Neo4j):存储节点和边。针对遍历查询优化。用于社交网络、知识图谱和推荐系统。
    • 向量数据库(Pinecone、Milvus、Weaviate、FAISS):存储高维 embedding,支持近似最近邻(ANN)搜索。是语义搜索、RAG(retrieval-augmented generation)和推荐系统的核心组件。

CAP 定理

  • 在分布式数据库中,最多只能同时满足以下三个属性中的两个:

    • 一致性(Consistency):每次读取都返回最新写入的值。
    • 可用性(Availability):每个请求都能收到响应(即使部分节点宕机)。
    • 分区容错性(Partition tolerance):系统在网络分区(节点无法通信)时仍能继续运行。

CAP 定理:由于网络分区不可避免,只能选择 CP(一致)或 AP(可用)

  • 由于分布式系统中网络分区不可避免,真正的选择是 CP(一致但在分区期间可能不可用——例如 PostgreSQL)与 AP(可用但在分区期间可能返回陈旧数据——例如 Cassandra、DynamoDB)之间的取舍。

  • 对于 ML:feature store 通常选择 AP(稍旧的 feature 值好于无预测结果)。model registry 选择 CP(serving 错误的 model 版本是灾难性的)。

分片(Sharding)

  • Sharding 将数据库拆分到多台机器上,每个分片持有数据的一个子集。

  • 哈希分片:对 key 哈希确定分片。shard = hash(user_id) % num_shards。分布均匀,但使范围查询不可行。

  • 范围分片:每个分片持有一段 key 范围(分片 1 持有 A-G,分片 2 持有 H-N)。支持范围查询,但可能产生热点(若大量用户名以"S"开头)。

  • 重新分片问题:增加分片会使哈希映射失效。一致性哈希将数据迁移量最小化:增加第 n 个分片时,只有约 1/n 的 key 需要迁移。

数据库索引

  • 索引是一种以额外存储和较慢写入换取更快查询的数据结构。没有索引,查询扫描每一行(\(O(n)\));有索引,则以 \(O(\log n)\) 找到目标。

  • B 树索引(默认):一棵平衡树(第 13、14 章),每个节点包含多个 key 和指针。B 树对 cache 友好(宽节点适合 cache line),支持范围查询(WHERE age BETWEEN 20 AND 30)。大多数 SQL 数据库使用 B 树。

  • 哈希索引:通过哈希函数将 key 映射到行位置。\(O(1)\) 查找,但不支持范围查询。用于精确匹配查找(WHERE id = 12345)。

  • 复合索引:在多列上建立索引。CREATE INDEX ON users(country, city) 可加速按 country 过滤或按 country + city 过滤的查询,但不能单独按 city 过滤(最左列必须在查询中)。

  • 权衡:每个索引加快读取但降低写入速度(每次插入/更新/删除时必须更新索引),并占用存储(每个索引约占表大小的 10-30%)。不要对所有列建索引——只对频繁查询的列建索引。

  • 对于 ML 系统:feature store 的在线数据库需要在实体 key(user_id、item_id)上建索引以实现快速 feature 查找。实验跟踪数据库需要在(experiment_id、metric_name)上建索引以支持仪表板查询。

API 设计

  • 系统通过 API 进行通信。良好的 API 设计使系统易用、可演化且便于调试:

  • REST 惯例:资源使用名词(/users/models),操作使用 HTTP 方法(GET = 读取,POST = 创建,PUT = 更新,DELETE = 删除),结果使用状态码(200 = 成功,201 = 已创建,400 = 请求无效,404 = 未找到,429 = 速率受限,500 = server 错误)。

  • 分页:对返回列表的端点,不要一次性返回全部结果。使用基于游标的分页(GET /items?cursor=abc&limit=50)或基于偏移量的分页(GET /items?offset=100&limit=50)。基于游标的方案对大数据集更高效(基于偏移量的方案需要跳过大量行)。

  • 版本管理:在 API 路径前加版本前缀(/v1/predict/v2/predict)。这允许在不破坏现有 client 的情况下演化 API。client 可以按自己的节奏迁移到 v2;v1 废弃但不立即删除,直到流量下降。

  • 错误响应:返回包含足够调试信息的结构化错误:

{
    "error": {
        "code": "INVALID_INPUT",
        "message": "Feature 'user_age' 必须是正整数",
        "details": {"field": "user_age", "value": -5}
    }
}

消息队列

  • 消息队列将生产者(生成工作的服务)与消费者(处理工作的服务)解耦。生产者向队列发送消息;消费者在准备好时拉取消息。

  • 队列的意义:没有队列时,消费者变慢或宕机会阻塞生产者。有了队列,生产者发送即忘;队列缓冲消息直到消费者就绪。

  • Apache Kafka:分布式、持久化、高吞吐量的消息队列。消息存储在 topic 中,每个 topic 跨多个 broker 分区。消费者从分区读取,跟踪自己的位置(offset)。Kafka 保证分区内有序,并支持消息回放(日志是持久化的)。

  • 发布/订阅(Pub/sub):发布者向 topic 发送消息;所有该 topic 的订阅者都收到一份副本。用于事件驱动架构:"新 model 已部署"同时触发监控服务、A/B 测试服务和日志服务。

  • 对于 ML:预测请求通过 HTTP 到达,被放入 Kafka 队列,由 GPU worker 处理,结果通过回调或 WebSocket 返回。队列缓冲流量突发,确保 GPU worker 崩溃时不会丢失请求。

一致性模型

  • 在分布式系统中,不同节点可能持有不同版本的数据。一致性模型定义了系统提供的保证:

  • 强一致性:写入后,所有后续读取(来自任意节点)都能看到新值。易于推理,但速度慢(需要节点间协调)。

  • 最终一致性:写入后,读取可能在一段时间内看到旧数据,但最终会看到新值。速度快(无需协调),但要求应用能处理陈旧读取。

  • 因果一致性:如果操作 A 在因果上先于 B(例如"先写 X 再读 X"),系统保证 B 能看到 A 的结果。但不相关的操作可以以任意顺序出现。

  • 读己写:用户始终能立即看到自己的写入,即使其他用户看到的是陈旧数据。这是大多数应用所需的最低一致性保证。

弹性设计模式

  • 速率限制(Rate limiting):限制每用户每时间窗口内的请求数量。防止滥用并确保公平访问。使用 Redis 中的令牌桶或滑动窗口计数器实现。

  • 断路器(Circuit breaker):如果下游服务开始失败(错误率超过阈值),断路器"打开",停止向其发送请求(立即返回降级响应)。超时后,断路器"半开"并发送一个测试请求。若测试成功,则"关闭"(恢复正常)。这防止级联故障:若 feature store 宕机,model server 返回无 feature 的预测,而不是在每个请求上都超时。

  • 背压(Backpressure):当系统过载时,向上游发出减速信号。系统不是接受请求后再失败,而是提前拒绝多余请求(返回 429 或 503 状态码)。client 使用指数退避进行重试。

  • 指数退避重试:请求失败时,等待 1 秒后重试。再次失败,等待 2 秒。然后 4 秒、8 秒,以此类推。添加抖动(随机延迟)以防止所有 client 同时重试(惊群问题)。

  • 幂等性(Idempotency):若一个操作执行两次与执行一次效果相同,则该操作是幂等的。PUT /user/123 {"name": "Alice"} 是幂等的(两次将名字设为"Alice"没有问题)。POST /payments 不是(重复支付是严重错误)。使操作幂等确保重试是安全的。