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、云厂商 NLBNginx、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 里的对应物工作在
四层 LBService(ClusterIP / NodePort)+ 每个节点上的 kube-proxy四层
后端列表EndpointSlice(Service 背后当前可用的 Pod IP 列表)—
主动健康检查readinessProbe(失败就从 EndpointSlice 摘掉)—
七层 LBIngress / Gateway API,由 Nginx、Envoy 等控制器实现七层
云厂商 NLBtype: LoadBalancer 的 Service四层
源 IP 哈希sessionAffinity: ClientIP四层
保留真实源 IPexternalTrafficPolicy: Local四层
优雅关闭里那个 sleeppreStop 钩子—
客户端侧 LBHeadless Service + gRPC 客户端负载均衡七层
SidecarIstio / Linkerd 等服务网格七层

等后面研究K8S的时候,我们在一个一个做一遍。