系统设计基础


文档摘要

系统设计基础 系统设计(systems design)研究的是如何构建能在大规模下稳定运行的软件。本文件覆盖客户端-服务器架构、网络协议、DNS、代理、负载均衡、缓存、数据库、消息队列、一致性模型与容错模式 生产环境中的每一个 ML 系统本质上都是分布式系统。一个推荐引擎绝不仅仅是一个模型——它是一个 API 服务器、一个特征存储、一个模型注册表、一层缓存、一个消息队列,再加上一整套监控系统,全部通过网络互相通信。理解系统设计,正是区分"我训了个模型"和"我做了一个产品"的关键。 顶级科技公司(Google、Meta、Amazon、OpenAI)的系统设计面试,考的就是你能不能设计出这些系统。

系统设计基础

系统设计(systems design)研究的是如何构建能在大规模下稳定运行的软件。本文件覆盖客户端-服务器架构、网络协议、DNS、代理、负载均衡、缓存、数据库、消息队列、一致性模型与容错模式

  • 生产环境中的每一个 ML 系统本质上都是分布式系统。一个推荐引擎绝不仅仅是一个模型——它是一个 API 服务器、一个特征存储、一个模型注册表、一层缓存、一个消息队列,再加上一整套监控系统,全部通过网络互相通信。理解系统设计,正是区分"我训了个模型"和"我做了一个产品"的关键。

  • 顶级科技公司(Google、Meta、Amazon、OpenAI)的系统设计面试,考的就是你能不能设计出这些系统。本章会给你提供基础构件(本文件)、云基础设施(第 02 节)、扩展模式(第 03 节)、ML 专属设计(第 04 节)以及完整案例(第 05 节)。

客户端-服务器架构

  • 最基础的模式:客户端(client) 发出请求,服务器(server) 处理后返回响应。你的浏览器(客户端)向 google.com(服务器)发一个 HTTP 请求,服务器返回 HTML。

  • 请求-响应模型(request-response model):同步的。客户端要等响应回来。简单,但会形成瓶颈:客户端等待时一直空闲,服务器也必须处理完一个请求才能继续下一个。

  • 无状态服务器(stateless servers):服务器不记得之前的请求。每个请求都自带处理它所需的全部信息。这让扩展变得容易:任何服务器都能处理任何请求,所以你可以在负载均衡器后面加更多服务器。

  • 有状态服务器(stateful servers):服务器在请求之间维护状态(比如用户会话)。更难扩展,因为同一个用户的请求必须发到同一台服务器(会话亲和性,session affinity)。现代系统尽量避免在服务器端保存状态,而是把它存到数据库或缓存(Redis)里。

网络协议

  • 我们在第 13 章介绍过网络(TCP/IP 分层、套接字)。这里聚焦系统设计中用到的应用层协议:

  • HTTP/HTTPS:Web 和绝大多数 API 的协议。请求方法有:GET(读取)、POST(创建/预测)、PUT(更新)、DELETE(删除)。HTTPS 在其上加了 TLS 加密(第 13 章的安全部分)。REST API(第 15 章第 03 节)就构建在 HTTP 之上。

  • WebSockets:客户端与服务器之间持久的双向连接。HTTP 是"请求 → 响应 → 连接关闭",而 WebSocket 会一直保持连接打开,用于实时流式传输。典型用途:LLM 的 token 流式输出(边生成边发送 token)、实时仪表盘、聊天应用。

  • gRPC:Google 的 RPC 框架。基于 HTTP/2 使用 Protocol Buffers(二进制序列化,比 JSON 小约 10 倍、快约 10 倍)。支持流式传输(服务端流、客户端流、双向流)。用于对性能敏感的内部服务间通信。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)的客户端和服务器代码。类型安全、向后兼容、高性能,全都免费拿到。

DNS

  • DNS(域名系统,Domain Name System)把人类可读的名字翻译成 IP 地址(第 13 章)。对系统设计来说,DNS 还提供:

  • 通过 DNS 做负载均衡:对同一个域名返回不同的 IP 地址,把流量分散到多台服务器。简单但粒度粗(DNS 结果会被缓存几分钟到几小时,所以流量没法快速重新均衡)。

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

  • 故障转移(failover):如果某台服务器宕机,DNS 不再返回它的 IP。新客户端会被引导到健康的服务器。但已缓存的 DNS 记录意味着部分客户端还会在几分钟内继续访问死掉的服务器(这就是 TTL 问题)。

代理

  • 代理(proxy) 是客户端和服务器之间的中间人:

  • 反向代理(reverse proxy)(放在服务器前面):客户端连到代理,代理再把请求转发给后端服务器。客户端并不知道是哪台服务器处理的请求。NginxHAProxy 是标准的反向代理。它们提供:负载均衡(分发请求)、SSL 终止(在代理处解密 HTTPS,向后端发送明文 HTTP)、缓存、限流和压缩。

  • API 网关(API gateway):专门为 API 设计的反向代理。处理鉴权、限流、请求路由(不同路径 → 不同服务)和 API 版本管理。KongAWS API GatewayEnvoy 是常见选择。

  • 对 ML 服务而言:API 网关放在你的模型服务器前面。它校验 API key,给免费档用户限流,把 /v1/predict 路由到模型服务器 A、/v2/predict 路由到模型服务器 B,并收集用量指标。

负载均衡

  • 当你有多台服务器时,负载均衡器(load balancer) 把到来的请求分发到各台服务器上。

负载均衡器把到来的请求分发到多台后端服务器

  • 算法

    • 轮询(round robin):按顺序把请求发给服务器(1、2、3、1、2、3……)。简单、公平,但不考虑服务器负载。
    • 最少连接(least connections):发给当前活跃连接数最少的服务器。更适合处理时间差异大的请求(有些 LLM 请求生成 10 个 token,有些生成 1000 个)。
    • 加权轮询(weighted round robin):容量更大的服务器分到更多请求。一台 80 GB 显存的 GPU 服务器处理的请求数,是一台 40 GB 服务器的 2 倍。
    • 一致性哈希(consistent hashing):把请求的键哈希到某一台特定服务器。同一个键总是去同一台服务器。用途:缓存(同一个用户的请求命中同一份缓存)、会话亲和,以及前缀缓存(第 17 章:带有相同系统提示词的请求,发到已经缓存了该提示词 KV-cache 的那台服务器)。
  • L4 与 L7 负载均衡

    • L4(传输层):基于 IP 和端口路由。快,但无法查看请求内容。
    • L7(应用层):基于 HTTP 路径、头部或正文内容路由。可以把 /api/chat 路由到聊天服务器,把 /api/embed 路由到嵌入服务器。慢一些,但更灵活。

缓存

  • 缓存(caching) 把频繁访问的数据放在一层快速存储(内存)里,避免重复计算或重复抓取。

旁路缓存模式:先查缓存,未命中则从数据库取并写入缓存以备下次使用

  • 缓存模式

    • 旁路缓存(cache-aside,懒加载):应用先查缓存。未命中就从数据库取,写入缓存后返回。最常见的模式。
    • 写穿(write-through):每次写入同时写到缓存和数据库。保证缓存总是最新,但会拖慢写入。
    • 写回(write-back):只写缓存,缓存再异步刷到数据库。写入最快,但如果缓存崩了还没来得及刷盘,就有丢数据的风险。
  • 驱逐策略(eviction policies)(缓存满了时):

    • LRU(最近最少使用,Least Recently Used):驱逐最久没被访问过的条目。最常用的策略。
    • LFU(最不经常使用,Least Frequently Used):驱逐访问次数最少的条目。当某些条目持续热门时更合适。
    • TTL(存活时间,Time To Live):条目在固定时长后过期。用于会变陈旧的数据(模型预测缓存 5 分钟,特征值缓存 1 小时)。
  • CDN(内容分发网络,Content Delivery Network):面向静态内容(图片、JavaScript、CSS)的全球分布式缓存。在全世界 100 多个地点的服务器上,从离用户最近的地点提供缓存内容。对 ML 来说:模型权重可以缓存在 CDN 上以便快速下载。

  • Redis:标准的内存缓存/数据库。支持字符串、列表、集合、有序集合、哈希和流。亚毫秒级延迟。用途:缓存模型预测、存储会话数据、限流(统计每用户每分钟的请求数)以及实时特征服务。

  • 对 ML 服务而言:对重复输入缓存预测结果。如果很多用户都问"法国的首都是哪里?",算一次答案,之后直接返回缓存结果即可。对聊天机器人工作负载,20-40% 的缓存命中率很常见,能按比例降低 GPU 成本。

数据库

SQL(关系型)

  • SQL 数据库(PostgreSQL、MySQL)把数据存在带行列的表里。表之间的关系通过外键表达。查询用 SQL。ACID 保证:

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

NoSQL

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

    • 键值存储(key-value stores)(Redis、DynamoDB):最简单的模型。按键快速查找。用于缓存、会话存储和特征存储。
    • 文档存储(document stores)(MongoDB、Firestore):存类似 JSON 的文档。schema 灵活(每个文档可以有不同的字段)。用于用户画像、产品目录和配置。
    • 列族存储(column-family stores)(Cassandra、HBase):为写密集型工作负载和时间序列数据优化。用于事件日志、指标和分析。
    • 图数据库(graph databases)(Neo4j):存节点和边。为遍历查询优化。用于社交网络、知识图谱和推荐系统。
    • 向量数据库(vector databases)(Pinecone、Milvus、Weaviate、FAISS):存高维嵌入向量,支持近似最近邻(ANN)搜索。语义搜索、RAG(检索增强生成)和推荐系统的核心。

CAP 定理

  • 在分布式数据库里,下面三个性质你最多只能同时拥有两个:

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

CAP 定理:在网络分区不可避免的情况下,只能选 CP(一致)或 AP(可用)

  • 既然网络分区在分布式系统中不可避免,真正的选择是 CP(一致,但分区时可能不可用——如 PostgreSQL)还是 AP(可用,但分区时可能返回陈旧数据——如 Cassandra、DynamoDB)。

  • 对 ML 而言:特征存储通常选 AP(一个略陈旧的特征值,总比拿不到预测好)。模型注册表选 CP(线上服务的模型版本搞错了,后果是灾难性的)。

分片

  • 分片(sharding) 把一个数据库切分到多台机器上。每个分片持有数据的一个子集。

  • 哈希分片:对键做哈希来决定分片。shard = hash(user_id) % num_shards。分布均匀,但无法做范围查询。

  • 范围分片:每个分片持有一个键范围(用户 A-G 放分片 1,H-N 放分片 2)。支持范围查询,但可能产生热点(如果很多用户名字都以 "S" 开头)。

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

数据库索引

  • 索引(index) 是一种以额外存储和更慢写入为代价来加速查询的数据结构。没有索引时,查询要扫描每一行(O(n))。有索引时,能在 O(log n) 内找到目标。

  • B 树索引(默认):一种平衡树(第 13 章、第 14 章),每个节点包含多个键和指针。B 树对缓存友好(宽节点能塞进缓存行),支持范围查询(WHERE age BETWEEN 20 AND 30)。大多数 SQL 数据库都用 B 树。

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

  • 复合索引:在多个列上建索引。CREATE INDEX ON users(country, city) 能加速按 country、或 country + city 过滤的查询,但不能加速只按 city 过滤的查询(查询里必须包含最左边的列)。

  • 权衡:每个索引都会加速读,但拖慢写(每次插入/更新/删除都要更新索引),并占用存储(每个索引约为表大小的 10-30%)。别什么都建索引——只给经常查询的列建。

  • 对 ML 系统:特征存储的在线数据库需要在实体键(user_id、item_id)上建索引,以便快速查找特征。实验跟踪数据库需要在 (experiment_id, metric_name) 上建索引,供仪表盘查询。

API 设计

  • 系统之间通过 API 通信。好的 API 设计能让系统好用、可演进、可调试:

  • REST 约定:用名词表示资源(/users/models),用 HTTP 方法表示动作(GET = 读,POST = 创建,PUT = 更新,DELETE = 删除),用状态码表示结果(200 = 成功,201 = 已创建,400 = 请求错误,404 = 未找到,429 = 被限流,500 = 服务器错误)。

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

  • 版本管理:在 API 路径前加版本前缀(/v1/predict/v2/predict)。这让你能演进 API 而不破坏已有客户端。客户端按各自节奏迁移到 v2;v1 进入弃用期,但要等流量降下来才真正下线。

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

{ "error": { "code": "INVALID_INPUT", "message": "Feature 'user_age' must be a positive integer", "details": {"field": "user_age", "value": -5} } }

消息队列

  • 消息队列(message queues) 把生产者(生成工作的服务)和消费者(处理工作的服务)解耦。生产者把消息发到队列;消费者准备好后再去拉取。

  • 为什么队列重要:没有队列时,如果消费者慢了或挂了,生产者就会被阻塞。有了队列,生产者"发射后不管"(fire and forget);队列把消息缓冲住,直到消费者准备好。

  • Apache Kafka:分布式、持久化、高吞吐的消息队列。消息存在 topic(主题) 里,每个主题再分散到多个 broker 上做分区。消费者从分区读取,并跟踪自己的位置(offset,偏移量)。Kafka 保证分区内有序,并且可以重放消息(日志是持久化的)。

  • 发布/订阅(pub/sub):发布者把消息发到主题;该主题的所有订阅者都会收到一份副本。用于事件驱动架构:"一个新模型上线了"这件事同时触发监控服务、A/B 测试服务和日志服务。

  • 对 ML 而言:一个预测请求经 HTTP 进来后,被放进 Kafka 队列,由某个 GPU worker 处理,结果再通过回调或 WebSocket 返回。队列既缓冲了流量突发,又确保 GPU worker 崩溃时不会丢请求。

一致性模型

  • 在分布式系统里,不同节点对数据可能持有不同视图。一致性模型(consistency models) 定义系统提供哪些保证:

  • 强一致性(strong consistency):一次写之后,所有后续读(从任何节点)都能看到新值。推理简单,但慢(需要节点间协调)。

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

  • 因果一致性(causal consistency):如果操作 A 因果上先于 B(例如"先写 X 再读 X"),系统保证 B 能看到 A 的结果。但无关操作之间可能以任意顺序被看到。

  • 读己之写(read-your-writes):用户总是能立刻看到自己的写,即使其他用户看到的是陈旧数据。这是大多数应用所需的最低一致性。

容错模式

  • 限流(rate limiting):限制每用户每时间窗口的请求数。既防滥用,又保证公平访问。通常用 Redis 里的令牌桶或滑动窗口计数器实现。

  • 熔断器(circuit breaker):如果某个下游服务开始出错(错误率超过阈值),熔断器就"打开",停止向它发请求(直接返回兜底响应)。超时之后进入"半开"状态,发一个试探请求。如果试探成功,就"关闭"(恢复正常)。这能防止级联失败:如果特征存储挂了,模型服务器宁可返回不带特征的预测,也不要在每个请求上都超时。

  • 背压(backpressure):当系统被压垮时,它会向上游发信号让其减速。与其先接下请求再失败,不如提前拒绝多余请求(返回 429 或 503 状态码)。客户端则用指数退避重试。

  • 指数退避重试(retry with exponential backoff):如果一个请求失败,等 1 秒重试。再失败就等 2 秒。然后 4 秒、8 秒,依此类推。加上抖动(随机延迟),防止所有客户端同时重试(惊群效应,thundering herd problem)。

  • 幂等性(idempotency):如果一个操作做两次的效果和做一次相同,它就是幂等的。PUT /user/123 {"name": "Alice"} 是幂等的(把名字设成 "Alice" 两次没问题)。POST /payments 不是(付两次款就很糟)。让操作幂等,才能安全地重试。


发布者: 作者: HenryNdubuaku 转发
评论区 (0)
U