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 在回环上、且无法做 “七层改写/缓存” 这类需要看到响应的功能。