1. 负载均衡解决的到底是什么问题
1.1 从一台机器被压垮说起
我最早接触负载均衡,不是什么高大上的架构设计,而是被现实逼的。当时一个内部系统,单台服务器跑得好好的,日常两三百个并发访问毫无压力,直到某个活动日流量翻了六倍,接口响应从 80ms 一路飘到 4 秒,最后连接池打满、进程假死。运维同学重启了三次,每次重启后十分钟又挂。那时候我们才意识到,问题不在于代码写得多烂,而在于整条链路只有一个出口——所有请求都压在同一个进程、同一块网卡、同一颗 CPU 上。
负载均衡要解决的就是这件事:把涌入的请求分散到多台机器上,让整体吞吐能力接近线性叠加,同时通过健康探测把已经出问题的节点摘掉,避免用户请求打到"死马"身上。它不是一个单独的软件,而是一种思路,可以落在硬件设备上,可以落在内核网络转发上,也可以落在应用层的反向代理里。
对读者来说,无论你是刚学后端的学生,还是已经写了几年业务代码的工程师,理解负载均衡的收益都很直接:你能看懂公司架构图里那一层是干嘛的,能自己给项目配一个可用的分发入口,更重要的是,当线上出现"部分用户慢、部分用户快"这类诡异现象时,你知道该往哪个方向查。
1.2 它在整条链路里站在哪个位置
先建立一个位置感。典型的请求链路长这样:
[ 用户 ] | v [ DNS 解析 ] --- 图1:请求进入系统的第一道分发 | v [ 负载均衡入口 ] --- 图2:本篇的主角,横向扩展的闸门 | +--> [ 应用节点 A ] +--> [ 应用节点 B ] +--> [ 应用节点 C ] | v [ 缓存 / 数据库 ] --- 图3:真正难扩展的部分这张图里有两个关键认知,很多人第一次做架构时都栽在上面。
第一,负载均衡解决的是"横向扩展"问题,不是"性能优化"问题。如果单个请求本身就慢,比如一个查询要扫三百万行数据,你加十台机器也只是让十倍的慢请求同时发生,用户该等还是等。负载均衡的前提是每个节点都能独立、正确地处理请求,并且单请求耗时在可接受范围内。
第二,负载均衡本身也可能成为新的单点。图2 那个方框如果只有一台,它挂了整个系统就没了,这就是为什么后面一定要讲高可用(第 5 节)。很多人第一次搭建时只做了分发,没做入口冗余,结果可用性反而比单机还低——因为多了一个必须存活的中间层。
理解这两点,你就明白为什么业界会同时存在这么多种实现:从内核层的 ECMP、传输层的 LVS、应用层的 Nginx 和 HAProxy,到服务网格里的边车代理。它们不是互相替代的关系,而是站在链路的不同高度上,各自解决一段问题。
2. 四层与七层:选错层级等于白折腾
2.1 四层负载均衡看的是什么
四层指的是传输层,典型协议是 TCP 和 UDP。四层负载均衡在做转发决策时,能看到的只有源 IP、源端口、目的 IP、目的端口,以及协议号。它不知道 HTTP 请求里写的是/api/order还是/api/user,也看不到 Cookie、Header 和请求体。
工作方式很朴素:收到一个 TCP 连接,按算法挑一台后端,然后做地址转换或者直接转发 IP 包,把这条连接的所有数据包都送到同一台后端。整个过程中它基本不碰应用数据,所以性能极高,单机扛几十万并发连接不是罕见的事。
客户端 ---- SYN ----> [ 四层 LB ] | | 记录连接表:Client_X:Port -> Backend_B v [ Backend B ] --- 图4:四层只认连接,不认内容代价是灵活性差。你没法根据 URL 做路由,没法做基于 Cookie 的会话保持,没法在转发时改写请求头,也很难做精细的限流和灰度。它的信条是"连接建立时决定一次,之后绝不反悔"。
提示:四层负载均衡的连接表是它的命脉。当后端节点下线时,已经建立的连接不会自动迁移,只会被重置或超时断开。这一点在设计长连接业务时必须提前考虑。
2.2 七层负载均衡看的是什么
七层工作在应用层,它会把 TCP 流重新组装成完整的 HTTP 请求,看清楚方法、路径、Header、Cookie,然后才决定转发给谁。这也是为什么 Nginx 能写出location /api/这种配置——它真的读懂了请求内容。
多出来的解析成本换来的是大量实用能力:按域名分流、按路径分流、改写 Header、注入追踪 ID、做 A/B 测试灰度、按请求维度限流、统一处理跨域和压缩。现代业务里,入口层几乎都跑在七层。
客户端 -- GET /api/order --> [ 七层 LB ] | | 解析出 path=/api/order | 命中 location /api/ -> 后端组 api_pool v [ api_pool 某节点 ] --- 图5:七层按内容决策2.3 怎么选:四个判断维度
实际选型时我一般看四点,不问"哪个更好",只问"哪个够用"。
| 判断维度 | 偏向四层 | 偏向七层 |
|---|---|---|
| 协议类型 | 非 HTTP,如数据库、消息队列、游戏长连接 | HTTP/HTTPS、gRPC 等可解析协议 |
| 路由需求 | 不需要按路径、域名分流 | 需要灰度、多租户、按路径分流 |
| 性能压力 | 单机数十万连接,极致吞吐 | 单机数万连接,需要功能换性能 |
| 运维复杂度 | 配置简单,排查靠连接表 | 配置灵活,排查需看访问日志 |
真实生产里更常见的是组合:最外层用四层做流量入口和抗量,内层用七层做业务路由。这样既保住了吞吐,又拿到了灵活性。我第一次做入口设计时图省事,全用七层,结果 HTTPS 握手和证书卸载的 CPU 开销在高峰期非常明显,后来把纯转发的那部分下沉到四层,机器数量直接省下来一小半。
3. 调度算法逐个拆解,别只会轮询
3.1 轮询、加权轮询与平滑加权
轮询是最容易理解的算法:后端列表按顺序一个一个发,发完回到第一个。它的隐含假设是"所有后端能力相同",这在同构集群里成立,在混合部署里基本不成立。
加权轮询给每台机器加一个权重,权重高的分到更多请求。关键在于实现方式。最朴素的实现是"按权重把节点重复填进列表再轮询",比如 A 权重 5、B 权重 1,列表就是 A A A A A B。这会导致流量在时间上严重不均:前五个请求全打给 A,第六个才给 B,短时间窗口内的抖动非常大。
平滑加权轮询解决了这个问题。它的做法是每个节点维护一个当前权重,每轮把配置权重加到当前权重上,选出当前权重最大的节点,然后给它的当前权重减去总权重。以 A(5)、B(1) 为例,总权重 6:
轮次 当前权重(A,B) 选中 选中后(A,B) 1 (5, 1) A (-1, 1) 2 (4, 2) A (-2, 2) 3 (3, 3) A (-3, 3) 4 (2, 4) B (2, -2) 5 (7, -1) A (1, -1) 6 (6, 0) A (0, 0) --- 图6:平滑加权轮询的六轮过程对比一下就很清楚:朴素实现是 AAAAA B,平滑实现是 A A A B A A,流量被打散了,后端负载曲线明显更平。Nginx 的weight参数默认就是平滑版本,这点不用自己操心,但你要知道它为什么平滑——排查"为什么权重没严格按比例"的时候,答案就在这里。
3.2 最小连接数与最快响应
最小连接数的思路是:谁手里的活少就给谁。它不关心权重,只关心当前活跃连接数或请求数。这个算法特别适合请求耗时差异极大的场景,比如一个接口有时 10ms 有时 3 秒,轮询会把慢请求均匀地摊到所有机器上,而最小连接数会自动把新流量倾斜给那些已经处理完的节点。
它有个明显缺陷:只统计"数量",不统计"成本"。某台机器上挂了十个超慢请求,它看起来连接数很少,于是新请求继续往它身上压,雪崩就是这么来的。所以实际使用中,最小连接数通常要配合合理的超时配置一起用,让慢请求及时被清掉。
最快响应则是直接看响应时间,谁回得快选谁。听起来最聪明,实际上最容易抖——响应时间是个高频波动的指标,如果采样窗口太短,流量会在节点之间来回跳,形成"震荡"。我一般只在后端节点硬件差异确实很大、且响应时间统计做了平滑的场合才用它。
3.3 哈希类算法与一致性哈希
有状态场景下,你会希望同一个用户的请求固定落到同一台机器。源地址哈希和 URL 哈希都是这个思路:对某个字段做哈希,对后端数量取模。
问题在于取模。后端从 4 台扩到 5 台,hash % 4变成hash % 5,绝大部分映射关系都会变,缓存全量失效,数据库瞬间被打穿。这就是扩容时最经典的"缓存雪崩"来源之一。
一致性哈希把节点和数据都映射到一个环上,每个请求顺时针找到第一个遇到的节点。增加一个节点时,只影响环上相邻区间的一小部分数据,其余映射关系保持不变。
[Node A] | [data3] --> [data1] (环上顺时针查找) | [Node B] | [data2] --> [Node C] --- 图7:一致性哈希环,新增节点只影响一段区间它带来的好处是扩容时迁移量约为1/N(N 为节点数),代价是数据分布可能不均。解决办法是虚拟节点:每个物理节点在环上映射出几百个虚拟点,分布就均匀了。这个数字我一般设 128 到 256 之间,太少不均匀,太多内存和查找开销上升。
3.4 等开销负载均衡:网络层把流量摊平
等开销负载均衡(Equal-Cost Multi-Path,通常简称 ECMP)是另一套思路,它不发生在应用层,而在路由层。当一台设备到某个目的地存在多条开销相同的路径时,它会把流量按哈希规则分摊到这些路径上。
+--> [ 链路1 / 下一跳A ] [ 源 ] --> [ 转发设备 ] +--> [ 链路2 / 下一跳B ] --- 图8:等价多路径,两条路径同时承载流量它的哈希键通常是"源 IP + 目的 IP + 协议号 + 源端口 + 目的端口"这五元组,所以同一条 TCP 流会被稳定地送到同一条路径上,不会出现乱序。这个特性非常关键:如果五元组被拆到不同路径,到达顺序错乱,接收端会大量重传,性能反而暴跌。
ECMP 的常见用武之地有两处:一是数据中心内部,把流量分摊到多条物理链路和多个上游设备;二是在大规模服务入口处作为最外层的流量分摊手段。它最大的优点是几乎不引入额外的处理延迟,缺点是不感知后端健康状态和服务质量,链路断了自己恢复依赖路由收敛速度,而且它对大象流(少数超大流量连接)无能为力——一条 10Gbps 的连接只能走一条路径,再怎么等开销也分不开。
所以我的经验是:ECMP 用来做"粗粒度摊平",应用层负载均衡用来做"细粒度调度",两者叠加,而不是二选一。
4. Nginx 负载均衡配置从能跑到好用
4.1 最小可用配置
Nginx 的入口配置就是upstream加proxy_pass。先看一个能跑起来的最小版本:
upstream backend { server 10.0.0.11:8080; server 10.0.0.12:8080; server 10.0.0.13:8080; } server { listen 80; server_name api.example.internal; location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里默认使用平滑加权轮询,三台机器权重都为 1。几个容易忽略的点:proxy_set_header Host $host一定要写,否则后端拿到的是backend这个上游组名,很多框架的域名校验和签名逻辑会直接失败。X-Forwarded-For用了追加形式,保留代理链,后端取真实 IP 时要取第一个非可信地址,不能直接取最后一个。
注意:
proxy_pass http://backend;后面没有斜杠,和proxy_pass http://backend/;的语义完全不同。前者会把原始 URI 原样透传,后者会把location匹配到的前缀替换掉。这一个斜杠的差别,我见过不止一次把接口全部打到 404。
4.2 权重、备份与优雅下线
生产配置比最小配置多出来的部分,几乎都是围绕"可控"两个字。
upstream backend { least_conn; server 10.0.0.11:8080 weight=5 max_fails=3 fail_timeout=10s; server 10.0.0.12:8080 weight=3 max_fails=3 fail_timeout=10s; server 10.0.0.13:8080 weight=1 backup; keepalive 64; }weight用来表达机器规格差异,新老机型混部时非常必要。max_fails和fail_timeout组成被动健康检查:在fail_timeout窗口内失败次数达到max_fails,节点被摘除fail_timeout时长。这两个参数默认是 1 和 10s,对于偶发抖动的服务太敏感了,我一般把max_fails提到 3,避免一次网络抖动就把节点踢出。
backup标记的是备用节点,只有主节点全部不可用时才接管流量。这个标记在灰度发版时特别好用:把新版本节点设为 backup,可以先用少量真实流量验证,出问题影响面极小。
下线节点时,正确做法不是直接删配置然后reload。直接删会让正在处理的请求被中断。更稳妥的是先加down标记,保留配置让存量连接自然结束,观察一段时间后再删除。
server 10.0.0.13:8080 down; # 保留配置,不再分发新请求keepalive 64是上游连接池,用来复用和后端之间的长连接。不加这个参数,Nginx 每个请求都要和后端重新三次握手,在高 QPS 下会吃掉大量端口资源和握手开销。经验值是按后端节点数乘以 16 到 32 来设,同时要配合proxy_http_version 1.1和清空Connection头,否则连接池根本不生效:
location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; }4.3 会话保持的三种做法
会话保持是个绕不开的话题。有三种常见做法,我按推荐度从高到低排。
第一是把状态外移,会话数据放 Redis 或数据库,应用节点本身无状态。这是最优解,因为一旦无状态,任何算法都能用,扩缩容也不影响用户。代价是要多一次外部访问,通常用本地缓存加过期时间来缓解。
第二是基于 Cookie 的会话保持。Nginx 的ip_hash是按客户端 IP 哈希,问题在于大量用户共享出口 IP 时,流量会严重倾斜到同一台机器上。如果一定要在代理层做,我更倾向用hash $cookie_sessionid consistent;,按业务自己的会话 ID 做一致性哈希,分布比 IP 均匀得多,而且客户端换网络也不影响。
第三是粘性 Cookie 注入,由代理层在响应里种一个标记后端编号的 Cookie,后续请求按这个标记路由。功能最精确,但需要额外模块,且用户禁用 Cookie 时失效。
| 方案 | 分布均匀度 | 扩容影响 | 实现成本 |
|---|---|---|---|
| 状态外移 | 高 | 无 | 中,需要外部存储 |
| 会话 ID 一致性哈希 | 高 | 小,仅迁移部分 | 低 |
| IP 哈希 | 低,受出口 IP 影响 | 大 | 低 |
| 代理注入粘性 Cookie | 高 | 中 | 中,需要模块支持 |
4.4 超时与缓冲:把慢后端隔离开
负载均衡最怕的不是后端挂掉,而是后端变慢。挂掉会被健康检查摘除,变慢则会持续占用连接和线程,最终把代理层自己拖死。
proxy_connect_timeout 2s; proxy_send_timeout 10s; proxy_read_timeout 10s; proxy_next_upstream error timeout http_502 http_504; proxy_next_upstream_tries 2; proxy_buffering on;proxy_connect_timeout设短一点是必须的,2 秒足够完成同机房握手。proxy_read_timeout要根据业务最长耗时来定,设太小会误杀正常的长请求,设太大则慢后端会长期霸占连接。
proxy_next_upstream是重试机制,遇到连接错误或超时,自动换一台后端重试。这里有个大坑:如果请求不是幂等的(比如创建订单),重试可能导致重复下单。所以我的做法是默认只对error timeout重试,并结合proxy_next_upstream_tries 2限制次数;对于写接口,要么在业务层做幂等键,要么用location单独关掉重试。
5. 高可用:别让入口变成新的单点
5.1 主备与双活的差别
入口层做高可用,最经典的是主备模式:两台代理机器,一个持有虚拟 IP 对外服务,另一个待命,通过心跳互相探测,主节点失联后备节点接管虚拟 IP。这样对上游完全透明,切换通常在秒级。
它的缺点是备机平时完全闲置,资源利用率只有 50%。而且切换瞬间,已建立的连接会断,对长连接业务不友好。
双活模式是两台都对外服务,通过 DNS 轮询或上层再放一层 ECMP 把流量分给两台。资源利用率上去了,但带来两个新问题:一是两台机器之间的会话状态不同步,用户可能一会儿打到 A 一会儿打到 B;二是 DNS 轮询的客户端缓存不可控,摘除一台后仍有大量请求按缓存打到已下线节点。
我的取舍是:中小规模用主备,简单可靠,出问题概率低;规模大到备机成本不可忽略时,用 ECMP 加多台代理做双活,因为路由收敛比 DNS 缓存可控得多。
5.2 健康检查的粒度和抖动
健康检查的粒度决定了故障发现的快慢,也决定了误判的概率。
| 检查方式 | 发现速度 | 误判风险 | 适用场景 |
|---|---|---|---|
| TCP 端口探测 | 快,秒级 | 低,但可能误判"进程在但业务挂" | 快速剔除宕机节点 |
| HTTP 状态码探测 | 中 | 中,依赖探测路径本身稳定 | 大多数 HTTP 服务 |
| 业务语义探测 | 慢 | 低 | 核心链路,需验证依赖可用 |
| 被动统计(失败率) | 快 | 受流量影响,低流量时不准 | 兜底,配合主动探测 |
只做 TCP 探测是最常见的偷懒做法,后果是后端进程还在 listen,但数据库连接池已经打满,所有请求都返回 500,而负载均衡依然认为它健康,继续往上面灌流量。至少要做到 HTTP 层探测,并且探测路径要选一个真正会访问数据库或缓存的接口,而不是一个直接 return 200 的假接口。这一点很多人栽过:探测接口写成了return "ok",结果它永远健康,形同虚设。
抖动处理也有讲究。探测连续失败两次才判定不健康,连续成功三次才判定恢复,这叫"去抖"。如果一失败就摘、一成功就加,节点会在健康与不健康之间反复横跳,流量跟着忽上忽下,监控曲线上会出现密集的锯齿。
6. 常见问题与排查技巧实录
6.1 问题速查表
下面这张表是我自己攒的,基本覆盖了负载均衡相关问题的九成场景。
| 现象 | 常见原因 | 排查动作 |
|---|---|---|
| 流量全打到一台机器 | 权重配错、会话保持生效、连接池粘住 | 看分发统计,检查upstream与哈希配置 |
| 部分用户间歇性 502 | 后端主动断连、连接池复用了已关闭连接 | 检查上游keepalive与后端 keepalive 超时配合 |
| 上线后缓存大面积失效 | 节点数变化导致取模哈希重排 | 换一致性哈希,或预热缓存 |
| 后端所有机器负载都很低但用户慢 | 代理层本身成为瓶颈,worker 数或连接数不够 | 看代理机器 CPU、连接数与工作进程数 |
| 扩容后新节点几乎没有流量 | 客户端 DNS 缓存、连接未重建 | 检查客户端连接池与 DNS 缓存时间 |
| 切流后仍有请求到旧节点 | 客户端长连接未断、注册中心推送延迟 | 等待连接自然老化,配合优雅下线 |
6.2 几个我踩过的坑
第一个坑是长连接导致的流量固化。客户端用了连接池并保持长连接,负载均衡在连接建立时就把这条连接钉在了某台后端上,于是十条长连接可能全落到同一台机器。看起来负载均衡生效了,实际分布极不均匀。解决办法是在客户端侧设置连接的存活时间上限,比如 60 秒就重建;或者在服务端设置 idle 超时,强制连接轮换。
第二个坑是健康检查导致的惊群。后端恢复的瞬间,所有探测同时成功,流量瞬间全量涌入,刚恢复的节点立刻又被打挂。应对方式是给恢复加权重爬坡,让新上线的节点权重从 1 逐步升到满值,给它喘息时间。
第三个坑是代理层和后端的超时时间不匹配。代理设置 10 秒超时,后端业务最长要跑 15 秒,结果后端把活干完了,返回的响应已经没有接收方,既浪费资源又产生大量超时日志。原则永远是:外层超时大于内层超时,逐层递增,形成清晰的超时预算。
提示:排查负载均衡问题时,第一步永远是确认"请求到底去了哪台机器"。在响应头里带上后端节点标识,比翻十份日志都快。
7. 压测验证与容量估算
7.1 到底需要几台机器
估算逻辑其实很简单:先算出总承载需求,再除以单机能力,最后留出余量。
假设峰值 QPS 是 3000,单台应用节点压测下来稳定能扛 500 QPS(P99 控制在 200ms 内),那么理论上需要3000 / 500 = 6台。但这只是起点,还要考虑三个修正。
一是余量。至少留 30% 冗余,应对流量突增和节点故障,所以6 / 0.7 ≈ 8.6,取 9 台。二是故障冗余。要保证挂掉任意一台仍有足够容量,所以多备一台,共 10 台。三是水位上限。单机压测的 500 QPS 是极限值,长期跑在这个水位上抖动会很大,实际按 70% 作为工作点,即 350 QPS,那么3000 / 350 ≈ 8.6,同样是 9 到 10 台。
需求 QPS 3000 单机安全水位 350 (500 极限 x 70%) 理论台数 3000 / 350 ≈ 8.6 加故障冗余 10 --- 图9:容量估算的推导路径需要强调的是,这个计算里的单机能力必须来自真实压测,不能拍脑袋。很多团队这块全靠猜,结果要么资源浪费,要么一上线就崩。
7.2 压测怎么做才有意义
压测最容易犯的错是只压后端、不压入口。实际上如果你是初次搭建入口层,代理层本身可能就是瓶颈。压测要从最外层打进去,观察整条链路的 P99 和错误率。
压测流量要尽可能贴近真实:请求体大小要还原、缓存命中率要接近线上、连接复用策略要和真实客户端一致。用短连接猛打和用长连接稳定打,得到的结果可能差两倍以上。
观察指标上,我会同时看四个:入口层的活跃连接数、后端各节点的 QPS 分布、P99 响应时间、以及错误率。其中 QPS 分布最能说明问题——如果各节点之间的差异超过 20%,先去查算法和权重配置,别急着加机器。
还有一点,压测要测故障场景。手动下线一台后端,观察流量是否在预期时间内完成重分布,以及期间错误率有没有尖峰;把入口的一台代理停掉,看备用是否正常接管。这些演练过的场景,才是真正故障时能救命的东西。
最后分享一个我在实际项目里的体会:负载均衡的配置从来不是一次写对的,而是被打出来的。每次故障复盘,我都会回头看一眼当时的upstream和超时参数,往往能发现一两个当时觉得无所谓、事后看致命的细节。把这些细节沉淀成配置模板和检查清单,比记住任何算法公式都管用。