Summary
在实际工作中,一台服务器的处理能力总有上限。业务长大之后,唯一的出路是加机器——但加了机器,立刻冒出一个新问题? 用户的请求,该发给哪一台?
之前讲 DNS的时候,DNS轮询能做一点分流,但它的问题很明确:没有健康检查(机器挂了照样往那导)、受 TTL 限制(摘不掉、切不快)、粒度太粗(按网段,不按实际负载)。
而负载均衡器(Load Balancer,LB) 就是专门干这件事的组件:站在服务器集群前面,把进来的流量按某种策略分发到后端各台机器,并且把挂掉的机器自动摘掉。
┌──────────┐
用户 ──────────▶ │ LB │
└────┬─────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ 服务器1 │ │ 服务器2 │ │ 服务器3 │
└────────┘ └────────┘ └────────┘
它带来的价值不只是"分摊压力":
- 扩展性:加机器就能提升容量,LB 自动纳入。
- 高可用:某台挂了自动摘掉,用户无感。
- 可运维:滚动发布时逐台摘流量、升级、放回,做到不停机发布。
四层与七层:本质区别是"拆到第几层"
LB 收到一个包后,拆到第几层就停下来,决定了它能看到什么、能做什么。
收到的数据帧
┌──────┬──────┬──────┬──────────────┐
│ 帧头 │ IP头 │TCP头 │ HTTP 报文 │
└──────┴──┬───┴──┬───┴──────┬───────┘
│ │ │
【四层 LB】拆到这 【七层 LB】一直拆到这
只看 IP + 端口 能看到 URL、Header、Cookie、Body
一句话概括:四层 LB 只看信封,七层 LB 拆开信读内容。
用邮局分拣类比:
- 四层像按邮编分拣:只看信封上的地址,不关心里面写了什么。极快,一秒能分几万封,但你没法要求"所有投诉信送到客服部"——因为它不知道哪封是投诉信。
- 七层像拆开信按内容分类:能看到"这是投诉"“这是订单”,可以做非常精细的分派。但慢得多,而且拆信这个动作本身有成本。
关键差异对照
| 四层 LB(传输层) | 七层 LB(应用层) | |
|---|---|---|
| 依据 | IP + 端口 | URL、Header、Cookie、方法、Body |
| 是否理解 HTTP | 否 | 是 |
| 性能 | 极高(几百万 QPS) | 较低(几万~几十万 QPS) |
| 连接 | 一条 TCP 直接转发 | 两条 TCP(客户端↔LB、LB↔后端) |
| 能否改内容 | 否 | 能(改 Header、重写 URL、压缩) |
| TLS 卸载 | 否 | 可以 |
| 典型实现 | LVS、F5、云厂商 NLB | Nginx、HAProxy、Envoy、云厂商 ALB |
| 对应 OSI | 第 4 层 | 第 7 层 |
“两条 TCP 连接"这一条是理解七层 LB 的关键。
七层 LB 必须先和客户端完整建立 TCP 连接(三次握手),才能读到 HTTP 请求内容,然后再另开一条连接去后端。所以它实际上是个代理,而不是"转发器”。
这解释了七层 LB 的一系列特性:
- 它可以修改内容(因为数据经它中转)。
- 它可以复用到后端的连接(一条后端长连接服务多个客户端请求,省掉大量握手。
- 它比四层慢(每个连接要维护两份状态,数据要在用户态进出)。
- 后端看到的源 IP 是 LB 的 IP(所以需要
X-Forwarded-For)。
四层负载均衡:快在哪
LVS 的三种模式
LVS(Linux Virtual Server) 是四层 LB 的经典实现,它在内核里工作——数据包不用进出用户态,这是它快的根本原因。
它有三种转发模式,差别在于"怎么把包送到后端、回包怎么走":
① NAT 模式:改地址,来回都经过 LB
客户端 ──▶ LB(改目标IP为后端) ──▶ 后端
客户端 ◀── LB(改源IP为VIP) ◀── 后端
问题:回包也必须经过 LB。 而 HTTP 的响应通常比请求大几十倍(请求几百字节,响应可能几百 KB),LB 的出口带宽会成为整个集群的瓶颈。
② DR 模式(直接路由):回包不经过 LB
客户端 ──▶ LB(只改目标MAC地址) ──▶ 后端
客户端 ◀──────────────────────── 后端(直接回!)
这是最精妙的一种。 LB 只修改数据链路层的 MAC 地址(不改 IP),把包"甩"给后端;后端配置了同样的 VIP(虚拟 IP),处理完直接把响应发回客户端,不再经过 LB。
请求路径 响应路径
客户端 ──▶ LB ──▶ 后端 后端 ──────────▶ 客户端
(绕开 LB,直接走)
为什么这很关键? 因为流量是极度不对称的——请求小、响应大。DR 模式下 LB 只处理进来的小请求,不碰出去的大响应,单台 LB 能支撑的集群规模大出一个数量级。
代价:LB 和后端必须在同一个二层网络(因为只改 MAC,包不能跨路由器),部署受限。
③ TUN 模式(IP 隧道):跨网段的 DR
用隧道技术:把原始 IP 包整个封装进另一个 IP 包送到后端,后端拆开处理,同样直接回包。
它解决了 DR"必须同网段"的限制——后端可以在任何地方,只要能建隧道。
四层 LB 的适用场景
- 超高并发的入口:先用四层扛住海量连接,再分给七层集群。
- 非 HTTP 协议:MySQL、Redis、gRPC 的裸 TCP、游戏私有协议——七层 LB 看不懂这些协议,只能用四层。
- 需要保留源 IP:DR 模式下后端直接看到客户端真实 IP,不需要 XFF 那套。
七层负载均衡:能干的事多得多
因为能读懂 HTTP,七层 LB 可以做四层做不到的事。
基于内容路由
# 按路径分发到不同的后端服务(微服务网关的基本能力)
location /api/quote/ { proxy_pass http://quote_service; }
location /api/swap/ { proxy_pass http://swap_service; }
location /static/ { proxy_pass http://cdn_origin; }
# 按 Header 分发 —— 灰度发布的实现方式
if ($http_x_canary = "true") { proxy_pass http://canary_backend; }
这是微服务网关和灰度发布的基础。 四层 LB 只知道"这是一个到 443 端口的连接",完全没法区分。
TLS 卸载
让 LB 统一处理 TLS 握手和加解密,后端之间走明文(或简化的内部 TLS),这也是目前很多公司的主流用法,nginx在前处理 TLS,后端业务服务只监听普通 HTTP:
用户 ──[HTTPS]──▶ LB ──[HTTP]──▶ 后端服务
↑
证书只装在这一处
好处很实际:
- 证书集中管理:换证书只改一个地方,不用给几十台后端逐个部署。
- 省后端 CPU:非对称运算集中在 LB,可以用专用硬件加速。
- 能读内容:不卸载 TLS 就看不到 HTTP 内容,七层能力全部失效。
注意安全边界:卸载之后 LB 到后端是明文的。如果这段链路不可信(跨机房、跨云),要么在内部也上 TLS,要么用 mTLS。“内网就是安全的"这个假设在零信任架构下已经不成立。
其他七层能力
- 重写与重定向:URL 改写、HTTP → HTTPS 强制跳转
- 压缩:统一做 gzip/brotli,后端不用管
- 限流与熔断:在入口就把超量流量挡掉
- 请求合并与缓存:Nginx 的
proxy_cache本质是个小型 CDN - 协议转换:HTTP/3 → HTTP/1.1、gRPC-Web → gRPC
调度算法:选哪台后端
不管四层七层,都要回答"这次发给谁”。常用几种:
| 算法 | 规则 | 适用 |
|---|---|---|
| 轮询(RR) | 依次分配 | 后端配置相同、请求耗时相近 |
| 加权轮询(WRR) | 按权重分配 | 后端配置不同(新机器权重高),Nginx 默认使用这个 |
| 最少连接(LC) | 发给当前连接数最少的 | 请求耗时差异大时明显更优 |
| 加权最少连接 | 上面两个的结合 | 生产最常用之一 |
| 源 IP 哈希 | 按客户端 IP 哈希 | 简易会话保持 |
| 一致性哈希 | 按 key 哈希到环上 | 缓存集群、有状态服务 |
| 最快响应 | 发给响应最快的 | 后端性能波动大 |
最需要理解的两个:
最少连接 vs 轮询:什么时候差别巨大
当请求耗时差异大时,轮询会造成严重的不均衡。
后端A 收到一个要跑 10 秒的大查询
后端B 收到的都是 10ms 的小查询
轮询: 照样一人一个轮着发 → A 越积越多,B 闲着
最少连接:A 手上一直有活(连接数高)→ 新请求自动都给 B ✅
“最少连接"本质是在用"当前连接数"这个指标间接感知后端的忙碌程度。 它不需要后端汇报任何东西,是一个非常朴素但有效的反馈机制。
报价引擎这类"有的查询快、有的查询慢"的场景,一定要用最少连接而不是轮询。
一致性哈希:为什么不用普通取模
如果你要做缓存集群,自然会想到 hash(key) % N 来决定这个 key 存在哪台机器。
问题:一旦增减一台机器,N 变了,几乎所有 key 的归属都会变。
3 台机器: hash("abc") % 3 = 1 → 机器1
加到 4 台: hash("abc") % 4 = 3 → 机器3 ← 变了!
结果:【几乎全部缓存失效】,所有请求瞬间穿透到数据库 → 雪崩
一致性哈希的解法是:把哈希空间想象成一个环(0 ~ 2³²-1),机器和 key 都映射到环上,key 顺时针找到的第一台机器就是它的归属。
环不好用字符画,下面把它从 0 处剪开拉成一条线,右端 2³²-1 和左端 0 首尾相接。“顺时针"就是"往右走,走到头从左边接着走”。
三台机器 A、B、C 和五个 key 映射到环上的位置:
0 2³²-1
├──k1────A────k2────k3────B────k4────C────k5─────────────┤
│ ▲ │ │ ▲ │ ▲ │
└─────┘ └─────┴─────┘ └─────┘ └─▶ 走到头,绕回 0 继续往右 → A
k1 → A k2、k3 → B k4 → C k5 → A(绕回)
每台机器负责的是"从上一台机器到自己"这一段:A 管 C 到 A(跨过 0 点),B 管 A 到 B,C 管 B 到 C。
现在在 k2 和 k3 之间加一台机器 D:
0 2³²-1
├──k1────A────k2──D──k3────B────k4────C────k5─────────────┤
▲
D 截走了"A 到 D"这一段
k1 → A k2 → D(原来是 B,需要迁移) k3 → B k4 → C k5 → A
五个 key 里只有 k2 换了机器。它原来归 B,现在 D 插在它和 B 之间,它往右先碰到 D。k3 在 D 右边,照样归 B;A 和 C 负责的区间完全没变,k1、k4、k5 不受影响。
减机器是反过来:把 D 拿掉,它那一段的 key 顺着往右交给下一台 B,其他机器还是不受影响。
关键性质:增减一台机器,只影响环上相邻的一段,平均只有 1/N 的 key 需要迁移。
实际实现要加"虚拟节点”:如果每台机器在环上只有一个点,分布会很不均匀(有的机器管一大段,有的管一小段)。做法是给每台物理机在环上放几百个虚拟节点,让分布趋于均匀。这是必须的,不是可选优化。
一致性哈希的应用远不止负载均衡:Redis Cluster 的槽位分配、分布式存储的数据分片、CDN 节点选择,都用它。做区块链的话,DHT(Kademlia)的节点定位思想也和它同源。
两个必须处理的工程问题
会话保持:有状态服务的麻烦
如果你的服务在内存里存了用户会话,那用户的后续请求必须回到同一台机器,否则会话就丢了。
一般有三种做法,优劣非常分明:
| 方案 | 做法 | 评价 |
|---|---|---|
| 源 IP 哈希 | 同一 IP 固定到同一后端 | ⚠️ 有问题:NAT 后大量用户共享 IP → 负载不均;用户 IP 变化 → 会话丢 |
| Cookie 粘滞 | LB 种一个 Cookie 记录后端 | ⚠️ 比 IP 哈希好,但扩缩容时仍会丢会话 |
| 共享存储 | 会话存 Redis,后端无状态 | ✅ 正解 |
最佳实践很明确:让服务无状态,别用会话保持。
之前讲HTTP的时候,说它一开始的设计就是无状态的——它牺牲单次效率换取水平扩展能力。而你在应用层引入状态,等于把这个好处主动丢掉了:扩容要考虑会话迁移、某台挂了那部分用户就受影响、滚动发布会踢掉在线用户,所以在设计 HTTP 服务的时候一定不要把这个好处丢掉。
用 Redis 存会话,或者用 JWT 让客户端自己带状态,都能让后端保持无状态。 这是架构上少数几个"没什么好权衡的、就是应该这么做"的点。
健康检查:LB 的生命线
没有健康检查的 LB 是危险的——它会持续把用户请求发给一台已经挂掉的机器。一般有两种检查方式,应该同时用:
主动检查:LB 定期发探测请求。
# 后端要提供一个专门的健康检查端点
location /healthz {
access_log off; # 别把探测日志刷满
return 200 "ok";
}
被动检查(熔断):根据真实请求的失败情况自动摘除。
upstream backend {
server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
# 30 秒内失败 3 次 → 摘除 30 秒后再试
}
健康检查端点该检查什么?这是个容易做错的设计题。
// ❌ 太浅:进程活着就返回 200,但数据库连不上照样收流量
func healthz(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}
// ❌ 太深:依赖任何一个下游抖动,整个集群一起被摘除 → 雪崩
func healthz(w http.ResponseWriter, r *http.Request) {
checkDB(); checkRedis(); checkAllRPCNodes(); checkKafka()
// 某个 RPC 节点抖了一下 → 所有实例同时不健康 → 全部被摘 → 服务完全不可用
}
// ✅ 分开两个端点(Kubernetes 的 liveness/readiness 就是这个思路)
func liveness(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) // 进程活着就行 → 决定"要不要重启我"
}
func readiness(w http.ResponseWriter, r *http.Request) {
if !db.Ping() { // 只检查【自己必需】的强依赖
http.Error(w, "not ready", 503)
return
}
w.Write([]byte("ok")) // → 决定"要不要给我发流量"
}
核心原则:健康检查只检查"我自己能不能干活",不检查"整个系统好不好"。
把共享的外部依赖放进健康检查是个经典陷阱:那个依赖一抖,所有实例同时变成不健康,LB 把所有后端全摘了,服务从"部分降级"变成"完全不可用"——健康检查本身成了故障放大器。
多层组合:真实架构长什么样
现实里不是"选四层还是七层",而是分层组合:
用户
│
┌───────────▼───────────┐
│ DNS │ 最粗:选机房/地区
└───────────┬───────────┘
┌───────────▼───────────┐
│ CDN │ 静态资源就近、TLS 卸载、抗 DDoS
└───────────┬───────────┘
┌───────────▼───────────┐
│ 四层 LB(LVS/NLB) │ 扛海量连接,分给七层集群
└───────────┬───────────┘
┌───────────▼───────────┐
│ 七层 LB(Nginx/Envoy) │ 按路径路由、限流、灰度
└───────────┬───────────┘
┌───────────▼───────────┐
│ 应用服务集群 │
└───────────────────────┘
每一层解决不同粒度的问题,由粗到细:
- DNS:全局、跨地域,秒~分钟级,不精确
- CDN:边缘、静态内容,毫秒级
- 四层:机房内、超高吞吐,不懂业务
- 七层:懂业务、能精细路由,吞吐较低
四层放七层前面,是因为四层便宜且能扛:一台 LVS 能扛几百万连接,把流量分给几十台 Nginx,每台 Nginx 再处理几万 QPS 的七层逻辑。用便宜的层扛量,用贵的层做精细活——这个分层思路在系统设计里到处都是。
服务网格(Service Mesh)是另一种形态:把七层 LB 的能力(路由、重试、熔断、可观测)下沉到每个服务实例旁边的 Sidecar 代理(Envoy),形成"每个服务都有自己的 LB"。
从 Go 的视角看
用标准库写一个反向代理
Go 标准库自带了七层反向代理,写一个能用的 LB 只要几十行:
import "net/http/httputil"
type Balancer struct {
backends []*url.URL
counter uint64
}
func (b *Balancer) next() *url.URL {
// 原子自增实现轮询
after := atomic.AddUint64(&b.counter, 1)
return b.backends[after%uint64(len(b.backends))]
}
func (b *Balancer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
target := b.next()
proxy := httputil.NewSingleHostReverseProxy(target)
// 关键:透传真实客户端 IP
proxy.Director = func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
req.Header.Set("X-Real-IP", host)
}
}
// 后端挂了的兜底
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("backend %s error: %v", target, err)
http.Error(w, "bad gateway", http.StatusBadGateway) // 502
}
proxy.ServeHTTP(w, r)
}
生产上更好的写法是复用 proxy 对象(别每次请求都 new,那样连接池建不起来),这里为了清楚才这么写。
后端服务要做的两件事
① 拿真实 IP
func realIP(r *http.Request) string {
if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
return xrip
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
⚠️ 同样的安全提醒:这些头可以被伪造。 只有当请求确实来自你信任的 LB 时才该采信——在入口的 LB 上强制覆盖这些头(而不是追加),是最可靠的做法。
② 优雅关闭:滚动发布不掉请求
// 收到退出信号后,先从 LB 摘掉自己,再等存量请求处理完
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop
// ① 让健康检查开始返回 503,LB 在下次探测时把我摘掉
atomic.StoreInt32(&shuttingDown, 1)
// ② 等 LB 真的摘掉我(至少一个探测周期)—— 这一步最容易被漏掉!
time.Sleep(5 * time.Second)
// ③ 停止接收新请求,等存量请求处理完
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
第 ② 步的 sleep 是关键,而且经常被漏掉。
如果收到信号立刻 Shutdown(),LB 还不知道你要下线,仍在往你这儿发请求,这些请求会直接被拒绝 → 用户看到 502。必须先让健康检查失败、等 LB 摘掉你之后,再真正关闭。
这是滚动发布"掉请求"最常见的原因,而且很隐蔽——发布时错误率有个小尖峰,不看监控根本发现不了。
客户端侧负载均衡
除了在服务端放 LB,还可以让客户端自己选后端——这正是你调多个 RPC 节点时该做的事:
type NodePool struct {
nodes []*Node
}
func (p *NodePool) Pick() *Node {
// 只从健康的节点里选,配合熔断([Go工程/08])
healthy := p.healthyNodes()
if len(healthy) == 0 {
return nil
}
// 最少连接:比轮询更适合耗时差异大的场景
sort.Slice(healthy, func(i, j int) bool {
return healthy[i].InFlight() < healthy[j].InFlight()
})
return healthy[0]
}
客户端侧 LB 的优势:少一跳网络、没有 LB 单点、能感知每个后端的真实响应情况。 gRPC 原生支持这种模式,服务网格的 Sidecar 也是它的一种形态。
落到 Kubernetes:前面每个概念都有对应物
前面讲的东西,在 Kubernetes 里全都被做成了 API 对象。写一份 YAML,背后就是一台 LB 被配置好了:
| 本篇概念 | Kubernetes 里的对应物 | 工作在 |
|---|---|---|
| 四层 LB | Service(ClusterIP / NodePort)+ 每个节点上的 kube-proxy | 四层 |
| 后端列表 | EndpointSlice(Service 背后当前可用的 Pod IP 列表) | — |
| 主动健康检查 | readinessProbe(失败就从 EndpointSlice 摘掉) | — |
| 七层 LB | Ingress / Gateway API,由 Nginx、Envoy 等控制器实现 | 七层 |
| 云厂商 NLB | type: LoadBalancer 的 Service | 四层 |
| 源 IP 哈希 | sessionAffinity: ClientIP | 四层 |
| 保留真实源 IP | externalTrafficPolicy: Local | 四层 |
| 优雅关闭里那个 sleep | preStop 钩子 | — |
| 客户端侧 LB | Headless Service + gRPC 客户端负载均衡 | 七层 |
| Sidecar | Istio / Linkerd 等服务网格 | 七层 |
等后面研究K8S的时候,我们在一个一个做一遍。