8.9 四层与七层负载均衡

一句话

四层只看五元组、最多再看一眼 TLS SNI 就转发,七层要终结连接、解析请求再转发;分界线不是“谁性能高”,而是“你要不要看请求内容”——要看内容,就得付 CPU、多一跳延迟,以及一个有状态代理的运维代价。

分界线:能看到什么,才能按什么分发

维度 四层(L4) 七层(L7)
能看到的 源/目的 IP、端口、TCP 标志、TLS 的 SNI 方法、路径、Host、Header、Cookie、Body
分发单位 连接(或 UDP 包) 请求(HTTP)或消息(gRPC)
是否终结连接 不改写应用数据(NAT/DR)或只做隧道 终结客户端连接,再向上游建新连接
每连接状态 一条转发表项 两条连接 + 解析状态 + 缓冲
单机量级 百万级并发、Mpps 级包转发 万级 rps/核、十万级并发
能否按路径/头路由 不能(除非按 SNI 粗分) 能
能否重试、改写、限流 连接级重试,不能改内容 请求级重试、改头、限流、鉴权
健康检查粒度 端口/三次握手 HTTP 状态码、业务字段
典型产品 LVS/IPVS、云 NLB、DPDK/LB、ECMP+anycast nginx、Envoy、HAProxy、云 ALB、API 网关

🔑 一句话判据:需要“看懂请求才能做的决策”(灰度、鉴权、重试、改写)只能放七层;只需要“把包送到一台机器”的,四层更便宜也更抗打。

看懂请求之后具体怎么改写、怎么回写响应,见 8.10 反向代理与转发策略。

边界正在模糊:四层可以按 TLS SNI 分流(不解密也能看到域名),七层也可以只做 TCP 隧道(stream 模块)。判断归属看“它有没有解析应用层协议”,而不是看产品名。

四层:四种转发模式,差在回包走不走 LB

模式 做法 回包路径 客户端 IP 可见 对 RS 的网络要求 瓶颈
NAT(DNAT) 改目的 IP 为 RS,回包经 LB 改回源 IP 经过 LB 需 X-Forwarded-For 或 proxy_protocol 才可见 RS 网关指向 LB LB 带宽双向
FullNAT 同时改源与目的 IP 经过 LB 需额外传递 RS 可在任意网段 LB 带宽 + 端口映射
DR(直接路由) 只改目的 MAC,IP 不变 RS 直接回客户端 可见(源 IP 就是客户端) 与 LB 同二层;RS 把 VIP 配在 lo LB 只处理入向
TUN(IP 隧道) 原包封进 IP 隧道给 RS RS 直接回客户端 可见 可跨网段,需支持 IPIP 隧道开销、MTU

DR 为什么快:LB 只改二层头,回包不经过它,所以 LB 的带宽只花在入向。代价是两个配置陷阱:

# RS 上必须做:否则 RS 会替 VIP 应答 ARP,流量绕过 LB 或产生 IP 冲突
ip addr add 10.0.0.100/32 dev lo          # VIP 配在 lo,不对外宣告
sysctl -w net.ipv4.conf.all.arp_ignore=1  # 只应答「目标 IP 就是本机地址」的 ARP
sysctl -w net.ipv4.conf.all.arp_announce=2 # 对外通告时用出口网卡的源 IP

NAT 的代价是状态:每条连接都要在 LB 上留一条 NAT 映射(conntrack),并且回包必须经过 LB,所以 LB 的出口带宽成为上限。这也是为什么大流量入口常见“L4 DR 在最前 + L7 在后”。

💡 云上 NLB 通常同时支持“保留客户端 IP”和“不保留”两种模式,前者类似 DR/透明转发,后者是 NAT——选错会直接导致后端拿到的是 LB 的 IP,风控与限流全部失效。

连接级 vs 请求级:长连接时代最贵的坑

四层按连接分发。长连接本身是对的——它把建连成本摊到 N 个请求上(8.3 连接复用与 TIME_WAIT);但连接活得越久,“按连接分发”就越接近“按首次建连时的快照分发”:

  • 扩容加进来的实例,初始连接数为 0,而老连接不会自己搬家;
  • 只有客户端重连(发布、空闲超时、网络抖动)时,流量才会重新分配;
  • HTTP/2 把每条连接的承载量放大、连接数压到接近常数(8.5 HTTP/1.1、HTTP/2、HTTP/3 (QUIC)),于是“分到一条连接”等于“分到一批请求”,不均被放大。

这不是理论问题,下面的仿真实测能给出具体时间:10 台后端 × 100 条长连接 = 1000 条连接,扩容到 12 台,两条新实例合计应得 16.7% 的连接。

package main

import (
	"fmt"
	"math/rand"
)

// 场景:10 台后端 × 100 条长连接 = 1000 条。扩容 2 台,总 12 台。
// 每条连接每分钟以 churn 的概率重连一次,重连时由 LB 重新选后端。
// 对比:轮询(均匀随机)vs 最少连接(选当前连接数最少的实例)。
const (
	oldBackends = 10
	newBackends = 2
	perBackend  = 100
)

type backend struct{ conns int }

// 每条连接等概率重连 → 源后端按连接数加权
func pickSource(bs []*backend, total int, rnd *rand.Rand) int {
	r := rnd.Intn(total)
	acc := 0
	for i, b := range bs {
		acc += b.conns
		if r < acc {
			return i
		}
	}
	return len(bs) - 1
}

func simulate(policy string, churn float64, minutes int) {
	bs := make([]*backend, oldBackends+newBackends)
	for i := range bs {
		bs[i] = &backend{}
	}
	for i := 0; i < oldBackends; i++ {
		bs[i].conns = perBackend
	}
	total := oldBackends * perBackend
	rnd := rand.New(rand.NewSource(7))
	target := 2 * float64(total) / float64(len(bs)) // 两台新实例应得的连接数

	fmt.Printf("%-8s churn=%2.0f%%/min ", policy, churn*100)
	half := 0
	for m := 1; m <= minutes; m++ {
		moves := 0
		for i := 0; i < total; i++ {
			if rnd.Float64() < churn {
				moves++
			}
		}
		for i := 0; i < moves; i++ {
			from := pickSource(bs, total, rnd)
			bs[from].conns--
			var to int
			if policy == "轮询" {
				to = rnd.Intn(len(bs))
			} else {
				to = 0
				for j := 1; j < len(bs); j++ {
					if bs[j].conns < bs[to].conns {
						to = j
					}
				}
			}
			bs[to].conns++
		}
		got := float64(bs[oldBackends].conns + bs[oldBackends+1].conns)
		if half == 0 && got >= target/2 {
			half = m
		}
		if m == 5 || m == 10 || m == 30 || m == 60 {
			fmt.Printf(" t=%2d:%4.1f%%", m, 100*got/float64(total))
		}
	}
	fmt.Printf("  | 应得 %.1f%%,到一半用 %d 分钟\n", 100*target/float64(total), half)
}

func main() {
	fmt.Println("=== 扩容的两个新实例多久能拿到流量(合计应得 16.7%)===")
	for _, c := range []float64{0.02, 0.05, 0.20} {
		simulate("轮询", c, 60)
		simulate("最少连接", c, 60)
	}
}

输出:

=== 扩容的两个新实例多久能拿到流量(合计应得 16.7%)===
轮询       churn= 2%/min  t= 5: 1.7% t=10: 2.8% t=30: 8.2% t=60:10.5%  | 应得 16.7%,到一半用 32 分钟
最少连接     churn= 2%/min  t= 5:11.1% t=10:16.4% t=30:16.6% t=60:16.6%  | 应得 16.7%,到一半用 4 分钟
轮询       churn= 5%/min  t= 5: 4.1% t=10: 6.5% t=30:13.2% t=60:16.0%  | 应得 16.7%,到一半用 14 分钟
最少连接     churn= 5%/min  t= 5:16.6% t=10:16.6% t=30:16.6% t=60:16.6%  | 应得 16.7%,到一半用 2 分钟
轮询       churn=20%/min  t= 5:10.7% t=10:14.1% t=30:17.6% t=60:16.3%  | 应得 16.7%,到一半用 4 分钟
最少连接     churn=20%/min  t= 5:16.6% t=10:16.6% t=30:16.6% t=60:16.6%  | 应得 16.7%,到一半用 1 分钟

读法很直接:

结论 数字
轮询 + 2%/min 重连 10 分钟只分到 2.8%,32 分钟才到应得的一半
轮询 + 5%/min 重连 14 分钟到一半——把客户端 maxLifetime 配上抖动,等于给 LB 加了“再平衡”能力
最少连接 1–4 分钟内基本均衡,因为它主动找“空”的实例
但最少连接比的是连接数,不是负载 每条连接承载的请求量差异大时一样失衡(HTTP/2 下尤其明显)

🔑 扩容不是“加机器”,是“让流量重新分配”。三件事缺一不可:① 客户端设置 maxLifetime + 随机抖动(让连接周期性重连);② LB 用最少连接/P2C,而不是纯轮询;③ 发布/扩容时主动摘流(draining)。

调度算法:选择依据,不在公式

算法 依据 优点 失效场景
轮询 / 加权轮询 请求序号 简单、无状态 后端异构或变慢 → 慢节点积压
最少连接 / 加权最少连接 当前连接数 自动避开慢节点 HTTP/2 下连接数恒定,退化成轮询
P2C(两次随机取优) 在途请求数 接近最优、无全局状态 需要准确的在途计数
一致性哈希 请求的某个键 缓存命中率高 节点变化导致热点迁移,需虚拟节点
源地址哈希 客户端 IP 天然会话保持 NAT 后大量客户端共用一个出口 IP

各类算法的数值对比与可运行仿真在 47.1 反向代理 的“轮询在慢节点面前会崩”一节,一致性哈希的倾斜与虚拟节点在 19.4 一致性哈希 与 19.5 虚拟节点,这里只给选型结论:在线服务默认最少连接或 P2C;有缓存/有状态分片才用一致性哈希;同构后端且流量均匀时才用轮询。

健康检查:摘除时间是可以算出来的

检查类型 探什么 能发现 发现不了
TCP 端口探活 完成三次握手(8.1 TCP/IP 与三次握手) 进程没监听、端口错 进程活着但线程池满了
HTTP 探活 GET /healthz 返回 200 依赖(DB/缓存)挂了、初始化未完成 业务逻辑错、部分接口慢
gRPC 健康检查 grpc.health.v1.Health/Check 服务注册状态 同上
被动检查 统计真实请求的失败率/超时 只有真实流量能暴露的问题 冷启动、低流量实例

摘除时间是可以算出来的:失败阈值 × 探测间隔。3 次 × 2s = 6 秒后才摘除,这段时间的流量全部打在坏实例上:

1000 QPS × 6s = 6000 个失败请求(还没算客户端重试放大)

要缩短,只有三条路:缩短间隔/阈值(代价是探测流量与误判)、被动摘除(连续 N 个 5xx/超时立即摘,不等下一轮探测)、让客户端带重试预算(见 35.5 超时、重试与退避)。

⚠️ 摘除要配主动摘流:发布时先 preStop 里让实例从 LB 摘除、等 1–2 个健康检查周期、再开始优雅退出。顺序反了就是“发布即 5xx”,现象与 8.10 反向代理与转发策略 里的转发超时混在一起,极难定位。

会话保持:什么时候该粘,什么时候是病

方式 原理 问题
源地址哈希 按客户端 IP 取模 NAT/代理后大量用户共用一个 IP → 全压一台
Cookie 插入 LB 插入含实例 ID 的 Cookie 首次请求落到哪台由算法定,之后粘住
Cookie 改写 改写上游种的 Cookie 需要理解上游的 Cookie 语义,容易改坏
一致性哈希 按业务键(用户 ID)分片 与“会话保持”不同:它保的是数据位置,不是会话

什么时候不该粘:服务本身已经无状态(会话放 Redis/DB)时,粘滞只会带来两个坏处——扩容不均衡、一台挂掉丢一批会话。判断标准很简单:如果粘滞被关掉,用户会不会掉登录?不会,就别粘(无状态化见 37.2 无状态化)。

容量与状态:LB 自己也会成为瓶颈

LB 不是“无成本的转发器”,它有三笔账:

账 量级 算例
包速率 10 Gbps 线速、64B 小包 = 14.88 Mpps(84B 线上:64 + 8 前导 + 12 帧间隙) 纯转发(DPDK/XDP)每核 5–10 Mpps;走内核协议栈 + conntrack 每核 1–2 Mpps
连接状态 conntrack 条目约 300B + 每连接 socket 缓冲约 4KB 100 万并发连接 ≈ 4.4 GB 内存
请求处理(L7) 每请求 CPU 预算 10 万 QPS / 8 核 = 每核 12500 rps → 每请求 80µs(含 TLS 记录加解密与 HTTP 解析)
# conntrack 水位与上限:打满的表现是内核日志 "nf_conntrack: table full, dropping packet"
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max     # 默认值常见在 6.5 万–26 万之间,随发行版/内存而变
conntrack -C                                    # 需要 conntrack-tools

# 四层转发与端口层面的观测
ss -s                                           # 各状态连接数汇总
nstat -az TcpExtListenOverflows TcpExtTCPReqQFullDrop

LB 自己怎么高可用,按故障切换时间排序:

方案 切换时间 特点
主备 + VRRP(keepalived) 秒级(3× advert) 备机平时闲置,容量浪费一半
ECMP + 多台同时工作 路由收敛秒级 需要一致性哈希或连接同步,否则连接断
anycast(BGP 宣告同一 IP) 秒级,但断连接 天然就近接入,适合无状态入口
DNS / GSLB TTL 级(分钟级) 客户端缓存不遵守 TTL,切换最慢

全局负载均衡:DNS、anycast 与就近

手段 粒度 能感知 切换速度 坑
DNS 轮询 机房/地域 配置 TTL,常 60s 起,实际更久 客户端/JVM 会缓存解析结果,绕过 DNS
GSLB(智能 DNS) 地域 + 健康 机房健康、运营商 TTL + 探测周期 递归 DNS 的出口位置不等于用户位置
anycast BGP 路由 网络可达性 秒级 路由抖动会断已有连接;不适合长连接
HTTP 重定向 / 302 调度 请求 应用层信息 立即 多一次 RTT,且暴露内部拓扑
客户端负载均衡 请求 全量实例列表 立即 需要服务发现与统一 SDK(见 42.4 服务发现与客户端负载均衡)

💡 “DNS 切换要 5 分钟”不是 DNS 慢,是没人遵守 TTL:操作系统 stub resolver、JVM 的 networkaddress.cache.ttl、移动端 SDK 都可能把结果缓存几十分钟。所以 DNS 只能做“分钟级、不保证”的容灾,做不到精确切流。

选型决策表

你的情况 选择 理由
公网入口,要扛 DDoS / 大流量 最外层 L4(DR/云 NLB)+ 内层 L7 L4 扛包速率,L7 做业务路由
需要灰度、鉴权、按路径路由 L7 L4 看不懂请求
纯 TCP/UDP 服务(数据库代理、游戏、MQTT) L4 没有应用层语义可解析
内部服务互调、已上 Service Mesh 客户端 LB / sidecar 少一跳,见 30.5 Service Mesh 与 Sidecar
K8s 内部服务 Service(L4,iptables/IPVS) 见 7.3 Service 与 Ingress
K8s 南北向入口 Ingress/Gateway(L7) 见 7.3 Service 与 Ingress
单一区域、实例少于 3 台 直接用 DNS + 客户端重试 LB 的运维成本 > 收益
需要“严格会话保持” 先改无状态,再谈粘滞 粘滞会把扩展性问题藏起来

七层在转发时会改写请求头(X-Forwarded-For、Host、Connection),多级叠加后的语义与信任边界见 8.12 代理链、跨域与重定向。

反模式

反模式 后果
长连接 + 纯轮询,扩容后不摘流 新实例空转,老实例打满,扩容“无效”
用连接数当负载指标(h2 场景) 最少连接退化成轮询,P99 抖动
只做 TCP 端口探活 进程活着但依赖全挂,流量照打
摘除阈值设 5×10s 故障后 50 秒内,落到该实例的请求全部失败
DR 模式不在 RS 上抑制 ARP 流量绕过 LB、VIP 冲突,现象是“时通时不通”
NAT 模式下 LB 只扩 CPU 不扩带宽 回包全走 LB,带宽先打满
conntrack 表按“够用”配 SYN flood 或突发连接把表打满,内核直接丢包
四层做灰度、七层做 DDoS 清洗 职责错位:该便宜的贵了,该强的不强
LB 主备但从不演练切换 真故障时 VRRP 抢不到 VIP / 脑裂
依赖 DNS TTL 做精确切流 客户端缓存导致切不干净,回滚也切不回来

自检

Q1 扩容 2 台后,新实例 CPU 接近 0,老实例打满;LB 控制台显示“分发均匀”。最可能的原因是什么?怎么验证?

▸ 答案:四层按连接分发 + 客户端长连接不重连。控制台的“均匀”统计的是新建连接,而存量连接不会迁移;新实例只分到新增连接,占比极低(实测 2%/min 重连率下,10 分钟只有 2.8%)。验证:在 LB 上看每实例的并发连接数而不是新建连接数,同时看客户端连接池的 maxLifetime/idleTimeout 是否为 0(永不过期)。改法:客户端设 maxLifetime + 抖动,或 LB 改用最少连接,或发布时主动 draining。

Q2 DR 模式为什么要求真实服务器把 VIP 配在 lo 上并设置 arp_ignore/arp_announce?不配会怎样?

▸ 答案:DR 只改目的 MAC,IP 仍是 VIP,所以 RS 必须“认领”VIP 才能接收;但 VIP 不能对外宣告,否则 RS 会应答 VIP 的 ARP,把本应发给 LB 的流量抢过来(或造成 IP 冲突、ARP 表混乱),表现为部分客户端直连到某台 RS、LB 统计不到流量、切换时连接错乱。配在 lo + arp_ignore=1(只应答本机地址的 ARP)+ arp_announce=2(用出口网卡源 IP 通告)就是“认领但不宣告”。

Q3 监控显示 LB 上 nf_conntrack_count 顶到 nf_conntrack_max,业务开始丢包。为什么 NAT 模式比 DR 模式更容易先炸?应急与根治各做什么?

▸ 答案:NAT 要改写地址,必须为每条连接维护 NAT 映射(conntrack 条目),100 万连接就是几个 GB 内存 + 每次新建连接的查表开销;DR 只改 MAC、回包不经过 LB,不需要 NAT 表,状态压力小得多。应急:调大 nf_conntrack_max 与 hashsize、清理僵死连接、把流量切到备用 LB。根治:入口改 DR/透明转发或换四层专用设备(DPDK/云 NLB),并给 conntrack 加水位告警(80% 预警),而不是等日志报 table full。

Q4 健康检查周期 2s、失败阈值 3,实例在第 0 秒挂掉。入口 1000 QPS 时,大约多少个请求会失败?如果客户端带 3 次重试,最坏情况放大到多少?

▸ 答案:摘除需要 3×2s = 6s,期间 1000 QPS × 6s ≈ 6000 个请求失败(假设流量均匀,且没有被动摘除)。带 3 次重试时,每个失败请求最多放大成 4 次尝试,最坏 2.4 万次请求打到故障实例或其邻居;如果重试没有预算、且下游同时变慢,就是雪崩的标准起点。所以:缩短检测、加被动摘除、给重试设预算(见 35.5 超时、重试与退避)。

Q5 DSR(直接路由)为什么能提升性能?限制是什么?

▸ 答案:因为 DSR 让响应包不经过 LB:LB 只改写请求的目的 MAC/IP,后端直接把响应发给客户端,省掉了回程带宽与一次转发。限制是:LB 与后端要在同一二层网络、后端要能配置 VIP 在回环上、且无法做 “七层改写/缓存” 这类需要看到响应的功能。

关联