承载更多负载的两种方式
欢迎
当服务开始不堪重负时,运维人员面临一个选择。要么让现有机器变得更大(更多 CPU、更多内存、更快的磁盘)。要么添加更多机器,每台机器执行相同的工作。
第一条路径被称为垂直扩展(向上扩展)。第二条路径被称为水平扩展(向外扩展)。
本教程将讲解为何几乎每种现代 Web 架构都选择水平扩展,以及工作负载的哪种特性使这一选择成为可能。答案隐藏在一个词中:状态。
到课程结束时,你将理解:
- 垂直扩展与水平扩展的成本曲线,以及各自适用的场景
- “有状态”与“无状态”在实际中的含义,以及为什么其中一种可以低成本地大量复制
- 在预期负载和突发负载下,计算副本集群规模的数学方法
- 防止某一层在排队拐点后崩溃的余量规则
- 状态必须存储在哪里(它永远不会消失),以及如何将状态从需要扩展的层中移出
为什么水平扩展在超过阈值后胜出
垂直扩展:一台更大的机器
优点:简单。无需修改代码。无需协调。同一个进程现在拥有了更多的 CPU。
缺点:存在上限。市面上最大的商用虚拟机具有有限的内存和核心数。超过这个限制,再多的钱也买不到更多的余量。一旦超过供应商产品最佳性价比的“甜点区”,成本会呈超线性增长。那台单一机器的故障会导致整个服务宕机。
水平扩展:许多较小的机器
优点:没有上限(取决于你愿意为机器支付的费用及协调成本)。容量随副本数量线性增加,可预测性强。单个副本故障仅移除 1/N 的容量,而非 100%。
缺点:需要工作负载支持。某些工作负载(如单个大型数据库、持有实时会话的有状态游戏服务器)难以进行水平扩展。协调与负载分配成为运维关注点。
临界点:任何需要承受单台机器故障的生产服务,都必须在至少两台机器上运行。一旦你接受了使用两台机器,实际上就已经选择了水平扩展。从此,问题不再是“我们该不该做?”,而是“我们能多便宜地增加下一个副本?”
关键使能因素:工作负载本身不在机器上保留每请求的状态。这样,任何副本都可以响应任何请求,且增加副本时无需协调即可增加容量。
实践中的有状态与无状态
状态不会消失,只会转移
有状态组件:持有信息,若丢失这些信息会导致行为改变。例如:存储用户账户的数据库、存储会话令牌的缓存、将长时间运行的流式连接固定到特定用户的 worker。
无状态组件:不持有任何丢失后会带来影响的信息。例如:读取请求、查询数据库并写入响应的 Web 层。每个请求都是独立的;该层在请求之间不保留任何记忆。
关键洞察:状态不会从系统中消失。它会转移到专门设计用于持有它的层(如数据库、Redis 集群、对象存储)。这样,面向流量的层就可以变为无状态,而无状态层可以水平扩展,因为任何副本都可以响应任何请求。
实用测试:如果你随机杀掉该层中的一个进程并重启它,是否会有任何用户遇到错误答案或丢失会话?如果是,则它持有状态。如果否,则它不持有状态。
示例
- 一个读取请求、查询 Postgres 并返回 JSON 的 Python Web 进程:无状态。状态存在于 Postgres 中。
- 一个在本地内存中持有用户购物车的 Python Web 进程:有状态。杀掉该进程会导致购物车丢失。
- 一个保持与聊天用户开放连接的 WebSocket 服务器:在连接意义上是有状态的。终止该进程会断开连接;客户端必须重新连接。通常,通过谨慎处理(如粘性会话、一致性哈希),这些服务仍然可以水平扩展。
- 位于 Postgres 前面的 Redis 缓存:对于缓存内容而言是有状态的,但如果可以容忍缓存未命中,则是可接受的。副本故障意味着缓存未命中,而不是数据丢失。
为水平扩展进行设计 = 将状态从需要扩展的层中移出。
审计可疑层级
一个团队在反向代理后方的 6 台后端虚拟机上运行推荐 API。该应用程序:从请求中读取用户 ID,从 Postgres 获取该用户最近的活动,运行评分算法,返回推荐项目列表。存在两个非标准行为:
- 应用程序在进程内存中维护一个“最近用户活动”缓存,在用户首次请求时填充,并在后续请求中复用。
- 应用程序使用粘性会话:一旦用户访问了 3 号虚拟机,其所有后续请求都将发送到 3 号虚拟机(代理被配置为基于 Cookie 的粘性路由)。
副本公式
最简单的容量公式
一旦某个层级变为无状态,其容量规划就变成了算术问题。你需要足够的副本数量,以确保稳态负载的到达速率与离开速率保持一致,并为突发流量预留余量。
公式:
副本数 = ⌈ (峰值负载 × 突发系数) / 单副本容量 ⌉ + 冗余量
其中:
- 峰值负载:正常运营时期预期的最大持续每秒请求数
- 突发系数:用于覆盖峰值之上短暂突发的乘数(对于可预测的流量通常为 1.5 倍至 2 倍,对于病毒式传播或不可预测的流量则为 3 倍或更高)
- 单副本容量:单个副本在可接受的延迟和资源利用率下处理的每秒请求数(通常测量于 70% CPU 利用率时,而非饱和状态)
- 冗余量:额外的副本数量,以确保少数副本故障不会导致该层级崩溃(对于小规模集群通常为 1-2 个副本,对于大规模集群则为 10-20%)
示例计算:后端每个副本在 70% CPU 占用率下可处理 100 req/s。峰值负载为 600 req/s。预计会出现偶尔 2 倍的流量激增。要求能够承受 2 个副本故障。
replicas = ⌈ (600 × 2) / 100 ⌉ + 2 = 12 + 2 = 14 个副本
80% 法则
单副本的容量并非饱和点。应在 70-80% CPU 占用率下测量容量,切勿在 100% 占用率下测量。
当利用率超过约 80% 时,排队曲线会急剧上升:在 60% 利用率下耗时 10 ms 的队列,在 90% 利用率下耗时可达 80 ms。首先被打破的是延迟,而非吞吐量。(配套课程 geometry_of_stateless_horizontal_scaling 从数学上推导了该曲线。)
自动扩缩容与静态配置
静态配置:按峰值 × 激增余量进行配置,并接受非高峰时段低利用率带来的成本。
自动扩缩容:控制器根据观察到的利用率、目标延迟或队列深度来增加或移除副本。
自动扩缩容注意事项:冷启动时间至关重要。如果新副本需要 2 分钟才能启动,自动扩缩容无法应对 30 秒内的流量激增。成熟的自动扩缩容策略会在扩容阈值以下维持一个由预置副本组成的“热池”。
为新服务确定集群规模
你的团队计划推出一项视频元数据 API。基准测试显示,单个副本在 70% CPU 占用率和 50 ms p99 延迟下可处理 250 req/s。市场部门预测,黄金时段的峰值负载为 4,000 req/s。计划中的促销活动可能会使流量短暂飙升至峰值的 3 倍。你希望该服务在 3 个副本同时故障的情况下仍能存活,且幸存副本的利用率不超过 80%。
冷启动、缓慢排空及其他实际边缘情况
真实集群存在真实的边缘情况
该公式假设副本能立即出现、立即接收流量,并立即停止接收流量。在生产环境中,这些假设均不成立。
冷启动:新副本需要启动操作系统、启动进程、加载配置、预热缓存并通过健康检查。耗时从 5 秒(容器重启)到 5 分钟(完整虚拟机启动 + 镜像拉取)不等。自动扩缩容无法响应短于此延迟的突发流量。
缓慢排空:从池中移除的副本需要时间来完成正在处理的请求,然后才能终止。否则,用户会看到被截断的响应。反向代理支持排空(停止接受新请求,完成活跃请求),但这需要几秒到几分钟的时间。
热池(Warm pool):生产集群维护一个由预配置但处于空闲状态的副本组成的池子,以便在收到信号时立即接管流量。这以较小的持续成本换取了对流量激增的快速响应。
连接排空(Connection draining)与立即终止(immediate kill):优雅停机至关重要。触发排空机制的 SIGTERM 信号比 SIGKILL 耗时更长,但不会中断用户的请求。
健康检查窗口:刚刚启动的副本可能在数据库连接池预热完成之前通过首次健康检查;此时代理服务器开始发送真实流量,导致前十几个请求延迟较高。请调整健康检查以测试真实路径,而不仅仅是进程存活状态。
粘性蔓延(Stickiness creep):即使是名义上无状态的层级,随着时间推移也会产生粘性(例如 CDN 缓存、DNS 解析器缓存、连接池)。对于那些行为表现不一致的“相同副本”,请保持警惕。
热池还是响应式自动扩缩容?
你的视频元数据 API(与上一题相同,规模设定为 51 个副本以应对平稳峰值及突发流量)在每次上传新的病毒式传播视频时,都会经历持续 30 秒、达到正常负载 5 倍的流量激增。目前的自动扩缩容从冷启动添加一个新副本需要 90 秒(包括镜像拉取和预热)。在这 90 秒的间隙内,延迟急剧上升,部分请求超时。
在约束条件下设计无状态层
综合
你已了解为何在超过小规模阈值后水平扩展更具优势,“状态”在实际中的含义,如何根据预期负载和突发负载来规划集群规模,以及水平扩展在边缘场景下的局限性。
应用全部四项。
为 feed.example.com(一个社交信息流 API)设计后端层级。约束条件:单副本容量为 70% CPU 下的 200 req/s;预期峰值负载为 1500 req/s;突发系数为 2.5 倍(偶尔出现热门故事);需承受 2 个副本同时故障;冷启动时间为 60 秒;突发流量可能持续 45 秒;预算允许保留部分闲置容量,但不允许进行 2.5 倍的永久预置。
本课程接下来的方向
本课程接下来的方向
你现在已经建立了一个关于无状态层(stateless tier)的实用心智模型:它为何能扩展、如何确定其规模、在边界处什么会失效,以及当你将其推向需要增长的层级之外时,状态必须迁移到哪里。
本课程中的下一课(cs_distsys_ingress_egress_separation)探讨了一个更微妙的问题:即使无状态层的规模设置得完美无缺,当入站和出站流量共享同一条网络路径时,它仍可能以出人意料的方式失效。一个经典的例子是代理试图连接自身;解决方案是将一个层级拆分为两个具有不同职责的层级。
配套课程:geometry_of_stateless_horizontal_scaling 推导了排队曲线、应用于副本集群的利特尔法则(Little's Law),以及 80% 利用率拐点的几何含义。
干得漂亮。继续前进。