Chapter 02

横向扩展与负载均衡

上一章立下了那条贯穿全书的分水岭:无状态的东西可以随便克隆、随便扩;一旦背着状态(数据),就被迫切分它、复制它,所有硬问题都从这里长出来。本章先把容易的那一半讲透——无状态层到底怎么扩起来,以及流量进来后由谁、按什么规则分到哪台机器。

本章建立的认知

  • 垂直扩展撞单机天花板且是单一故障域;水平扩展容量近线性,但前提是请求不绑定单机内存。
  • 无状态化 = 把 session / 会话状态推到外部(Redis / DB / token),使任意副本能服务任意请求——这是水平扩展能成立的硬前提。
  • L4(按 IP/端口转发)便宜、协议无关但看不到内容;L7(终结 HTTP)能按路径/Header 路由,代价是更耗 CPU、自己也得防成单点。
  • 分流算法各有偏向:轮询忽略负载、最少连接按在途请求自我纠偏、一致性哈希保住缓存局部性但热 key 时会倾斜。

2.1垂直扩展 vs 水平扩展:两堵不同的墙

单机扛不住时,第一反应往往是把这台机器换得更强——加 CPU 核、加内存、换 NVMe。这叫垂直扩展(scale up)。它的好处是几乎零改造:代码不动,数据库不动,机器配置升一档,吞吐就上去了。

问题是它撞两堵墙。第一堵是单机物理天花板:一台机器的 CPU 插槽、内存通道、PCIe 通道数都有上限,越往高端走,每多一分性能的单价越陡——从 64 核升到 128 核的钱,能买好几台 32 核的机器。第二堵更致命:这台越来越强的机器,是一个单一故障域(single failure domain)。它一掉电、一块内存条 ECC 报错、一次内核 panic,整个服务就归零。把所有鸡蛋装进一个越来越贵的篮子,篮子摔了就全没。

水平扩展(scale out)走另一条路:不追求单机更强,而是多放几台一样的机器,让它们分摊流量。容量随机器数近线性增长(近线性而非严格线性,因为协调、负载均衡、共享下游本身会吃掉一点),且单台挂掉只损失 1/N 容量而不是全部。这正是大规模系统的默认姿势。

机制:为什么水平扩展不是免费的

水平扩展能成立,有一个被很多人跳过的硬前提:同一个客户端的两次请求,落到任意一台机器上都必须能被正确处理。如果第一次请求把购物车存在了 A 机器的进程内存里,第二次请求被分到 B 机器,B 根本不知道这个购物车——水平扩展立刻失效。

于是问题被逼回了第 01 章那条分水岭:要水平扩展,请求就不能依赖单机内存里的状态。换句话说,必须先把服务做成无状态的。这就是 2.2 节。

表 2.1 · 垂直 vs 水平:两种扩展的取舍
维度垂直扩展(scale up)水平扩展(scale out)
改造成本 几乎为零,换机器即可 需先无状态化 + 引入负载均衡
容量上限 单机物理天花板,高端段单价陡升 近线性,受限于共享下游(如数据库)
故障域 单一故障域,整机即整服务 单台只损失 1/N,可故障转移
大规模系统默认 仅作短期止血或有状态层补充 无状态层的标准答案
回扣主线

垂直扩展和水平扩展的真正区别,不在"加在一台还是加多台",而在状态住在哪。状态被钉死在一台机器的内存里,就只能垂直扩;把状态搬出去,才能水平扩。这是第 01 章那把"切分/复制状态"尺子的第一次落地。

2.2无状态化:水平扩展的入场券

无状态服务 = 在两次请求之间,服务器进程里不保留任何特定客户端的数据;每个请求自带(或能从外部取到)它需要的全部上下文。

"无状态"不是说系统里没有状态——用户数据、订单、会话当然存在,它们只是不住在应用进程的内存里,而是被推到了外部存储。常见去处有三种:

  • 外部会话存储:把 session 放进 Redis 或数据库,应用进程只持有一个 session id。任意副本都能拿 id 去同一个 Redis 取回会话——副本之间不再有"我记得你、它不记得你"的差别。
  • 客户端自带令牌:把会话信息编码进一个签名 token(如 JWT,JSON Web Token,一种由服务端签名、客户端随每个请求带回的凭证)。服务端验签即可信任其内容,连外部存储都省了——代价是 token 一旦签发,提前作废它(如强制下线)变得麻烦。
  • 把状态彻底归还数据层:购物车、草稿这类数据直接写库/写缓存,应用层只做无记忆的计算与转发。

无状态化的回报是决定性的:所有副本变得完全可互换。负载均衡器可以把请求扔给任意一台;某台挂了,流量无缝切到别台;扩容只需克隆出更多一模一样的副本。这正是第 01 章说的"无状态的东西可以随便克隆"在工程上的兑现。

代价:无状态不是没有代价,只是把代价挪了位置

状态被推到外部后,每个请求都要多一次网络往返去取它(多一跳)——读 session 要查一次 Redis,验 token 要做一次验签。原本一次内存访问(约 100ns)的事,变成一次同机房 RTT(约 0.5ms,见第 01 章延迟阶梯,慢约 5000 倍)。这一跳是无状态化的固定税:用一点延迟,换来"任意副本服务任意请求"的自由。

预测一下

会话保持(sticky session,负载均衡器把同一用户一直钉在同一台后端)能让用户在一台机器上的内存会话一直有效,省掉那一跳。那么——sticky session 和"无状态化"是不是互相矛盾?先想 20 秒再展开。

展开思路

两者确实指向相反方向。sticky session 的本质是把状态留在某台副本上、并强制后续请求回到这台——它是一种妥协,不是无状态。它能省那一跳,但代价立刻回来:① 这台副本一挂,钉在它上面的用户会话全丢;② 负载没法真正均衡,热门用户都钉在某台会把它压垮;③ 扩容时新机器分不到老用户的流量。

所以工程上的取向很清楚:真正的无状态是把状态外移,sticky 只是临时妥协或迁移期过渡。值得记住的是,等到第 05 章讲 read-your-writes(读得到自己刚写的)一致性时,这个"把请求粘到某处"的念头会换一副面孔再出现——那时它要解决的是复制延迟,不是会话。这是同一类妥协的两次现身。

2.3负载均衡:L4 与 L7 各看得到什么

有了一池可互换的无状态副本,还缺一个把流量分配给它们的角色——负载均衡器(load balancer,下称 LB)。它站在客户端和后端池之间,决定每个请求去哪台。按它能看懂多少东西,分成两层,对应 OSI 网络模型的第 4 层(传输层)和第 7 层(应用层)。

L4:只看信封,不拆信

L4 负载均衡工作在传输层,它只读 TCP/UDP 报文头里的源/目的 IP 和端口,按这些信息决定把连接转发给哪台后端,不解析、也看不懂里面装的是 HTTP 还是别的。好比邮局只看信封上的地址投递,从不拆开看内容。

这带来两个直接特性:快且便宜——不做应用层解析,每秒能转发的连接数极高,CPU 开销小;协议无关——HTTP、gRPC、数据库连接、自定义二进制协议它都能转。但代价是它做不了任何基于内容的决策:没法按 URL 路径把 /api 和 /static 分到不同后端,也没法读 Header 做灰度路由,更没法终结 TLS。

L7:拆信读内容,于是能做更聪明的事

L7 负载均衡工作在应用层,它会终结(terminate)客户端连接、完整解析 HTTP 请求——读得到方法、路径、Header、Cookie。能看懂内容,就能做 L4 做不到的事:按路径/域名/Header 做内容路由、做TLS 终结(在 LB 这里解密,后端用明文 HTTP,省后端算力)、做会话保持、做请求级的限流与重写。

代价同样直接:更耗 CPU(解析 + 加解密都烧算力),单台吞吐低于 L4;而且因为它终结连接、握有路由逻辑,它自己更容易成为瓶颈和单一故障域——这一点是 2.3 的核心陷阱,下面单独点名。

L4 与 L7 负载均衡能看到的信息对比 客户端 HTTP 请求 L4 负载均衡 看得到:IP / 端口 看不到:路径 / Header L7 负载均衡 看得到:路径/Header/TLS 代价:更耗 CPU 后端副本 A 后端副本 B /api 服务 /static 服务 按连接盲分 按路径路由
图 2.1 同样一个 HTTP 请求,L4 只读到 IP/端口、只能按连接盲分给可互换的副本;L7 拆开请求读到路径,于是能把 /api 与 /static 路由到不同服务。 注意:L7 的"更聪明"全部来自它终结连接、解析内容——这份能力正是它更耗 CPU、更易成为瓶颈的同一个原因。
陷阱 · L7 自己会变成单点

L7 LB 终结所有连接、握着所有路由逻辑,流量全从它身上过。一旦它的处理能力被打满,整个后端池再大也救不了——所有请求都卡在入口。它从"分流的工具"变成了 SPOF(single point of failure,单一故障点)。防御不是把它做得更壮(那只是更贵的垂直扩展),而是让 L7 层自己也水平扩展:多台 L7 实例,前面再放一层无状态的 L4(或 DNS / Anycast)把流量分给这些 L7。把"谁来扩 LB"这个问题,用同一套水平扩展的思路递归地再解一遍。

表 2.2 · L4 与 L7 负载均衡
维度L4(传输层)L7(应用层)
看得到 源/目的 IP、端口 方法、路径、Header、Cookie、TLS
看不到 请求内容(无法按路径/Header 决策) —(内容全可见)
开销与吞吐 低开销、高吞吐、协议无关 解析+加解密耗 CPU,单台吞吐较低
能做的事 纯转发、按连接分流 内容路由、TLS 终结、会话保持、请求级限流
主要风险 无内容感知,灰度/路由能力弱 更易成瓶颈与单点,须自身水平扩展

2.4分流算法:把请求分给哪台后端

LB 选定后端的规则就是负载均衡算法。规则不同,在不同流量形态下的表现天差地别。三种最常见的,各自的偏向值得讲清。

轮询(round robin):公平但盲目

把请求按顺序一台台轮着发:第 1 个给 A,第 2 个给 B,第 3 个给 C,再回到 A。实现极简、绝对均匀——按请求数均匀。它的盲点正在这里:它完全不看后端当前的负载。如果请求耗时差异大(有的查一次缓存就返回、有的要跑一个重查询),轮询会把一个慢请求和一个快请求一视同仁地发出去,结果某台后端积压了一堆慢请求、却还在被继续派活——负载在算法看不见的维度上倾斜了。

最少连接(least connections):按在途请求自我纠偏

每来一个请求,发给当前在途连接数最少的那台后端。它的高明之处在于把"后端有多忙"间接量化了:一台后端如果积压了很多慢请求,它的在途连接数就高,算法会自动绕开它,把新请求导向更空闲的后端。所以在请求耗时不均(有慢有快)的场景里,最少连接比轮询抗压得多——它不需要后端主动上报负载,仅凭"还没返回的连接数"这个本地可观测量就完成了自我纠偏。

一致性哈希(consistent hashing):把同一个 key 钉到同一台

按请求里的某个 key(如用户 id、缓存 key)做哈希,让同一个 key 总是路由到同一台后端。它的价值不在均衡,而在局部性:同一用户的请求总落同一台,那台后端的本地缓存就一直是热的(缓存命中),会话亲和也天然成立。代价是当某个 key 特别热(明星用户、爆款商品)时,它会把这股流量死死钉在一台上,造成热点而算法本身无力分散。

埋一个伏笔

这里的一致性哈希只讲了它作为分流算法的"行为"——同 key 同后端。它真正巧妙的机制是:增删一台后端时只需重映射极少量 key(约 1/N),而不是几乎全部。这套"少搬"的机制,以及它为什么靠虚拟节点才能均匀,是第 03 章数据分区的主角。本章先记住它的分流偏向:保住局部性,但热 key 时会倾斜。

慢请求场景下轮询与最少连接的分流差异 轮询:只数请求个数,看不见慢请求堆积 LB 后端 A 3 慢请求积压 后端 B 空闲 下一个仍轮到 A → A 过载 最少连接:在途数高的 A 被绕开,新请求导向 B LB 后端 A 在途 3,跳过 后端 B 在途 0,接新请求 导向更空闲的 B → 负载自我纠偏
图 2.2 同样的慢请求流量,轮询按个数公平派活、把 A 派到过载;最少连接读到 A 的在途连接数偏高就绕开它,把新请求导向空闲的 B。 注意:最少连接并不需要后端上报负载,它只用"还没返回的连接数"这一个本地量就完成了纠偏——这是它抗慢请求的根本原因。
表 2.3 · 三种负载均衡算法的偏向与失效场景
算法优势不适用 / 失效场景
轮询 实现极简,按请求数绝对均匀,无状态 请求耗时不均时倾斜——慢请求堆在某台仍被继续派活
最少连接 按在途请求数自我纠偏,抗慢请求,无需后端上报 请求耗时差异极大、或连接长存(如长轮询)时信号会失真
一致性哈希 同 key 同后端,保住缓存局部性与会话亲和 单个热 key 把流量钉死在一台,造成热点且算法无力分散
回扣主线

本章四节走完一条线:水平扩展要求无状态化(把状态外移),无状态副本由负载均衡器分流,分流靠算法选后端。整条线之所以成立,全因为副本里没有状态——它们可互换、可随便分、可随便扩。从第 03 章起,故事翻面:状态太大单机放不下,无处可外移,只能就地切开。容易的一半到此为止。

自测

先合上教程,把答案写下来,再展开对照——能自己讲出"为什么"才算过关。

  1. 为什么说水平扩展之前必须先做无状态化?把这条因果链讲清楚。
  2. L4 和 L7 负载均衡各自看不到什么?这分别让它们做不了哪类事?
  3. 同样面对一批耗时不均的慢请求,为什么最少连接比轮询更抗压?它靠的是哪个可观测量?
展开参考答案

1. 水平扩展 = 多放可互换的副本,由 LB 把请求分给任意一台。但只要某副本在内存里存了客户端状态(如进程内 session),同一客户端的下一个请求被分到别的副本就会失败。所以"任意副本服务任意请求"这个前提,要求状态不绑定单机内存——必须先把状态外移(无状态化),副本才真正可互换,水平扩展才成立。

2. L4 工作在传输层,只读 IP/端口、不解析内容,所以看不到路径、Header、Cookie、TLS 内容——做不了内容路由、灰度、TLS 终结。L7 终结连接、完整解析 HTTP,内容全可见,几乎没有"看不到"的盲区;它的代价不在视野而在开销(耗 CPU、更易成瓶颈和单点)。

3. 轮询只按请求个数派活,看不见"某台已经堆了一堆慢请求还在跑",于是继续往过载的后端派;最少连接把请求发给当前在途连接数最少的后端——慢请求积压会抬高那台的在途连接数,算法据此自动绕开它。它靠的可观测量就是"还没返回的连接数",无需后端主动上报负载。

进阶挑战

当 L7 负载均衡器自己成了瓶颈

一个 L7 LB 终结全站 HTTPS、做内容路由,现在它的 CPU 被 TLS 握手和请求解析打满,后端池还很空闲。给出两种扩展这台 L7 LB 的办法,并各自说清它把瓶颈挪到了哪里、引入了什么新代价。

展开提示(不是完整答案)

办法一:L7 层水平扩展——部署多台 L7 实例,前面放一层无状态 L4(或 DNS 轮询 / Anycast)把流量分给它们。瓶颈从单台 L7 挪到"L4 入口 + 后端共享下游";新代价是会话保持/路由状态要做成可共享的,且多了一层。办法二:卸载 L7 的重活——把 TLS 终结下放到专用硬件/边缘节点,或把内容路由这类 L7 逻辑前移到 CDN/边缘。瓶颈被拆成多个更小的专用层;新代价是架构层数变多、调试链路变长。两条路都是同一个动作:用水平扩展的思路,把"扩 LB"这个问题递归地再解一次。

参考来源

  • Beyer 等《Site Reliability Engineering》(Google SRE Book)Ch.19–20,Load Balancing 章节,sre.google/sre-book。
  • Martin Kleppmann《Designing Data-Intensive Applications》Ch.1(可扩展性与无状态服务)。
  • NGINX 官方文档,HTTP / TCP 负载均衡与算法(round robin / least_conn / hash),docs.nginx.com。
  • Envoy Proxy 文档,L4/L7 代理与负载均衡策略,envoyproxy.io。