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 节。
| 维度 | 垂直扩展(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 的核心陷阱,下面单独点名。
/api 与 /static 路由到不同服务。
注意:L7 的"更聪明"全部来自它终结连接、解析内容——这份能力正是它更耗 CPU、更易成为瓶颈的同一个原因。
L7 LB 终结所有连接、握着所有路由逻辑,流量全从它身上过。一旦它的处理能力被打满,整个后端池再大也救不了——所有请求都卡在入口。它从"分流的工具"变成了 SPOF(single point of failure,单一故障点)。防御不是把它做得更壮(那只是更贵的垂直扩展),而是让 L7 层自己也水平扩展:多台 L7 实例,前面再放一层无状态的 L4(或 DNS / Anycast)把流量分给这些 L7。把"谁来扩 LB"这个问题,用同一套水平扩展的思路递归地再解一遍。
| 维度 | 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 时会倾斜。
| 算法 | 优势 | 不适用 / 失效场景 |
|---|---|---|
| 轮询 | 实现极简,按请求数绝对均匀,无状态 | 请求耗时不均时倾斜——慢请求堆在某台仍被继续派活 |
| 最少连接 | 按在途请求数自我纠偏,抗慢请求,无需后端上报 | 请求耗时差异极大、或连接长存(如长轮询)时信号会失真 |
| 一致性哈希 | 同 key 同后端,保住缓存局部性与会话亲和 | 单个热 key 把流量钉死在一台,造成热点且算法无力分散 |
本章四节走完一条线:水平扩展要求无状态化(把状态外移),无状态副本由负载均衡器分流,分流靠算法选后端。整条线之所以成立,全因为副本里没有状态——它们可互换、可随便分、可随便扩。从第 03 章起,故事翻面:状态太大单机放不下,无处可外移,只能就地切开。容易的一半到此为止。
自测
先合上教程,把答案写下来,再展开对照——能自己讲出"为什么"才算过关。
- 为什么说水平扩展之前必须先做无状态化?把这条因果链讲清楚。
- L4 和 L7 负载均衡各自看不到什么?这分别让它们做不了哪类事?
- 同样面对一批耗时不均的慢请求,为什么最少连接比轮询更抗压?它靠的是哪个可观测量?
展开参考答案
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"这个问题递归地再解一次。