Skip to content

系统设计基础

系统设计是关于如何构建能在规模上可靠运行的软件的学问。本章涵盖客户端-服务器架构、网络协议、DNS、代理、负载均衡、缓存、数据库、消息队列、一致性模型和弹性模式

  • 每个生产环境中的机器学习系统都是一个分布式系统。推荐引擎不仅仅是一个模型——它由API服务器、特征存储、模型注册中心、缓存层、消息队列和监控栈组成,所有这些组件都通过网络进行通信。理解系统设计,是区分"我训练了一个模型"和"我构建了一个产品"的关键。

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

客户端-服务器架构

  • 基本模式:客户端发送请求,服务器处理请求并返回响应。你的浏览器(客户端)向google.com(服务器)发送HTTP请求,服务器返回HTML。

  • 请求-响应模型:同步方式。客户端等待响应。简单但会产生瓶颈:客户端在等待时空闲,而服务器必须先处理完当前请求才能处理下一个。

  • 无状态服务器:服务器不记住之前的请求。每个请求包含处理它所需的全部信息。这使得扩展变得容易:任何服务器都可以处理任何请求,所以你可以将更多服务器放在负载均衡器后面。

  • 有状态服务器:服务器在请求之间维护状态(例如用户会话)。更难扩展,因为同一用户的请求必须路由到同一台服务器(会话亲和性)。现代系统通过将状态存储在数据库或缓存(Redis)中来避免服务器端状态。

网络协议

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

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

  • WebSocket:客户端和服务器之间的持久双向连接。与HTTP(请求→响应→连接关闭)不同,WebSocket保持连接打开以进行实时流传输。用于:LLM token流式输出(边生成边发送token)、实时仪表板、聊天应用。

  • gRPC:Google的RPC框架。使用Protocol Buffers(二进制序列化,比JSON约小10倍且更快),基于HTTP/2。支持流式传输(服务器端、客户端、双向)。用于对性能有要求的内部服务间通信。Triton推理服务器(第15章)和TensorFlow Serving使用gRPC。

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

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(域名系统)将人类可读的名称翻译为IP地址(第13章)。对于系统设计,DNS还提供:

  • 通过DNS进行负载均衡:为同一域名返回不同的IP地址,将流量分布到多台服务器。简单但粒度较粗(DNS结果缓存数分钟到数小时,因此流量不会快速重新平衡)。

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

  • 故障切换:如果某台服务器宕机,DNS停止返回其IP。新客户端会路由到健康的服务器。但缓存的DNS条目会导致一些客户端继续访问宕机服务器数分钟(TTL问题)。

代理

  • 代理是客户端和服务器之间的中介:

  • 反向代理(位于服务器前方):客户端连接到代理,代理将请求转发到后端服务器。客户端不知道是哪台服务器处理了请求。NginxHAProxy是标准的反向代理。它们提供:负载均衡(分发请求)、SSL终止(在代理处解密HTTPS,向后端发送普通HTTP)、缓存、限流和压缩。

  • API网关:一种专门用于API的反向代理。处理认证、限流、请求路由(不同路径→不同服务)和API版本管理。KongAWS API GatewayEnvoy是常见选择。

  • 对于ML推理服务:API网关位于模型服务器前面。它认证API密钥,对免费层用户进行限流,将/v1/predict路由到模型服务器A,/v2/predict路由到模型服务器B,并收集使用指标。

负载均衡

  • 当你有多台服务器时,负载均衡器将传入请求分发到各台服务器。

负载均衡器将传入请求分发到多台后端服务器

  • 算法

    • 轮询(Round Robin):按顺序将请求发送到服务器(1, 2, 3, 1, 2, 3...)。简单、公平,但不考虑服务器负载。
    • 最小连接数(Least Connections):发送到活动连接数最少的服务器。更适合处理时间可变的请求(一些LLM请求生成10个token,另一些生成1000个)。
    • 加权轮询(Weighted Round Robin):容量更大的服务器获得更多请求。80 GB GPU内存的服务器处理量是40 GB的二倍。
    • 一致性哈希(Consistent Hashing):将请求键哈希到特定服务器。相同的键总是路由到同一台服务器。适用于:缓存(同一用户的请求命中同一缓存)、会话亲和性,以及前缀缓存(第17章:相同系统提示词的请求路由到拥有该提示词KV-cache的服务器)。
  • L4 vs L7负载均衡

    • L4(传输层):基于IP和端口路由。速度快但不能检查请求内容。
    • L7(应用层):基于HTTP路径、头部或正文内容路由。可以将/api/chat路由到对话服务器,/api/embed路由到嵌入服务器。速度较慢但更灵活。

缓存

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

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

  • 缓存模式

    • 缓存旁路(Cache-aside)(惰性加载):应用程序先检查缓存。未命中时,从数据库获取,存入缓存,然后返回。最常见模式。
    • 写直达(Write-through):每次写入同时到缓存和数据库。确保缓存始终最新,但会降低写入速度。
    • 写回(Write-back):写入只到缓存;缓存异步刷入数据库。写入最快,但如果缓存在刷入前崩溃则有数据丢失风险。
  • 淘汰策略(缓存满时):

    • LRU(最近最少使用):淘汰最久未被访问的条目。最常用的策略。
    • LFU(最不经常使用):淘汰访问次数最少的条目。当某些项目持续受欢迎时更好。
    • TTL(生存时间):条目在固定时间后过期。用于会过时的数据(模型预测缓存5分钟,特征值缓存1小时)。
  • CDN(内容分发网络):全球分布的静态内容(图片、JavaScript、CSS)缓存。遍布100+个地点的服务器从距离用户最近的位置提供缓存内容。对于ML:模型权重可以缓存在CDN上以便快速下载。

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

  • 对于ML推理服务:缓存重复输入的预测结果。如果许多用户问"法国的首都是什么?",计算一次答案并返回缓存结果。对话机器人工作负载的缓存命中率通常在20-40%,按比例减少GPU成本。

数据库

SQL(关系型)

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

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

NoSQL

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

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

CAP定理

  • 在分布式数据库中,你最多只能拥有以下三个属性中的两个:

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

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

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

  • 对于ML:特征存储通常选择AP(略微过时的特征值比没有预测要好)。模型注册中心选择CP(提供错误的模型版本是灾难性的)。

分片

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

  • 哈希分片:通过哈希键确定分片。shard = hash(user_id) % num_shards。分布均匀但无法进行范围查询。

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

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

数据库索引

  • 索引是一种加速查询的数据结构,代价是额外存储和更慢的写入。没有索引,查询需要扫描每一行(\(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=OK、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": "特征'user_age'必须是正整数",
        "details": {"field": "user_age", "value": -5}
    }
}

消息队列

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

  • 为什么需要队列:没有队列时,如果消费者很慢或宕机,生产者就会被阻塞。有了队列,生产者发送后即可继续;队列缓冲消息直到消费者准备好。

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

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

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

一致性模型

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

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

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

  • 因果一致性:如果操作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秒等。添加抖动(随机延迟)以防止所有客户端同时重试(惊群效应)。

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