《后端架构设计·演进与治理》3.2 客户端负载均衡:算法、局部性与重试放大

从订单服务调用库存时一次慢节点触发客户端重试、把流量放大数倍的事故出发,对比轮询、随机、P2C 与最小连接数的取舍,说明同可用区优先的局部性与溢出条件,给出带重试预算的 Go 负载均衡实现与真实测试输出,并拆解多层重试在调用链上的完整放大算式。

一次重试把 30% 的延迟变成了 3 倍流量

arch-lab 的订单服务调用库存预占,客户端配置了 2 次重试。某个库存实例因为磁盘抖动,p99 从 80ms 涨到 1.2s。客户端超时后重试,P2C 又把重试打到另外两个实例;这两个实例因为承接了额外流量也开始变慢,重试范围继续扩大。10 分钟后,库存集群的总请求量是正常值的 3.1 倍,而原始下单量只下降了 8%。

这不是负载均衡算法单独能解决的问题。算法决定“下一次选谁”,重试预算决定“还要不要再试”,局部性决定“能不能选到近的实例”。三者要一起设计,否则一个慢节点就能通过客户端的重试逻辑放大成集群故障。站内 负载均衡算法 讲过基础算法,本节把它们放进订单链路的重试语义里比较。

P2C 为什么比轮询更抗慢节点

轮询假设所有实例的处理能力相同,随机假设请求成本独立;P2C 每次随机取两个实例,比较当前在途请求数除以权重,选较小的那个。它不需要全局计数,也不依赖实例自报负载,却能在一个实例变慢、在途数上升时迅速降低它的被选概率。

算法需要的信息慢节点表现主要风险
轮询实例列表慢节点持续接满一份流量权重变化要重启
随机实例列表期望均匀,尾部仍会命中小样本波动
最小连接数全局在途计数反应快计数热点与一致性成本
P2C两个候选的在途数反应快且无全局状态需要正确释放计数
一致性哈希键到实例的映射与健康无关热点键与迁移

P2C 的代价是“在途数必须准确”。如果调用方忘记在请求结束时释放计数,实例的在途数只增不减,很快会被永久排除;如果计数在超时后没有回收,也会出现同样的偏差。本节的实现把释放函数作为 Pick 的返回值,调用方必须显式调用,测试也围绕这一点设计。

局部性不是免费的

同可用区优先能把跨区延迟和出口流量降下来,但它会把故障域绑定在一起:当本地可用区只剩一个实例时,P2C 没有第二个本地候选,继续坚持本地等于把全部流量压到单点。实现里的规则是“本地至少两个 Ready 实例才只选本地,否则把远端加入候选”,让本地空闲实例仍因 P2C 比较获得更高概率,同时保留溢出能力。

本地状态候选集合预期行为风险
本地 ≥ 2 个仅本地延迟最低本地整体过载时无溢出
本地 = 1 个本地 + 远端本地优先但可溢出远端延迟与出口成本
本地 = 0 个全部远端保可用性跨区带宽与延迟上升
本地全部不健康全部远端快速切换远端容量是否足够

局部性策略必须和容量规划一起看。如果三个可用区按 1:1:1 部署,却要求“绝不跨区”,那么任意一个区故障都会让剩余容量只剩 2/3;如果业务要求 N+1,就不能把本地优先写成硬约束。

重试预算与重试放大

重试预算的核心是给重试设一个与原始流量成比例的上限。实现用令牌桶:每个原始请求按 ratio 补充令牌,每次重试消耗一个令牌;令牌不足时直接失败,不再重试。这样即使依赖整体超时,重试总量也不会超过原始请求的 ratio 倍。

重试会在调用链上逐层放大。若每一层都允许 20% 的重试,三层之后最深层看到的是 (1+0.2)^3 = 1.728 倍原始流量;如果每层重试 2 次而不是按预算,放大倍数会接近 3^3。站内 重试放大与退避抖动 给出了退避与抖动的完整讨论,本节只把预算接到负载均衡器上。

层级单层重试比例最深层放大说明
1 层10%1.10入口重试
2 层10%1.21入口 + 服务间
3 层10%1.33再加数据访问层
3 层20%1.73预算过宽
3 层50%3.38接近故障放大

可运行实现

下面的 Balancer 实现 P2C、本地优先与溢出,RetryBudget 实现按请求比例补充令牌的重试预算。两者都不依赖第三方库;Pick 返回释放函数,确保在途计数能回到零。测试文件与 3.1 的实现共享 Snapshot 和 Endpoint 类型,放在同一个包内编译。

// file: internal/platform/discovery/balancer.go
// tier: L1
// verify: go test ./internal/platform/discovery/ -count=1
package discovery

import "errors"
import "fmt"
import "math"
import "math/rand"
import "sort"
import "sync"

// Balancer selects endpoints with power-of-two choices (P2C) and prefers the
// caller's zone when it has at least two endpoints. In-flight counts make a
// slow instance lose comparisons before its health check fails.
type Balancer struct {
	mu        sync.Mutex
	endpoints []Endpoint
	localZone string
	inflight  map[string]int
	rnd       *rand.Rand
}

// NewBalancer builds a balancer. A fixed seed makes tests reproducible;
// production callers should pass time.Now().UnixNano().
func NewBalancer(localZone string, seed int64) *Balancer {
	return &Balancer{
		localZone: localZone,
		inflight:  make(map[string]int),
		rnd:       rand.New(rand.NewSource(seed)),
	}
}

// Update replaces the endpoint set with one registry snapshot.
func (b *Balancer) Update(s Snapshot) {
	b.mu.Lock()
	defer b.mu.Unlock()
	eps := make([]Endpoint, 0, len(s.Endpoints))
	for _, ep := range s.Endpoints {
		if ep.Addr != "" && ep.Weight > 0 {
			eps = append(eps, ep)
		}
	}
	sort.Slice(eps, func(i, j int) bool { return eps[i].InstanceID < eps[j].InstanceID })
	b.endpoints = eps
}

// Pick returns one endpoint and a release function. The caller must invoke
// release after the request finishes; forgetting it biases P2C forever.
func (b *Balancer) Pick() (Endpoint, func(), error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	cands := b.candidatesLocked()
	if len(cands) == 0 {
		return Endpoint{}, nil, errors.New("discovery: no ready endpoint")
	}
	chosen := 0
	if len(cands) > 1 {
		i := b.rnd.Intn(len(cands))
		j := b.rnd.Intn(len(cands) - 1)
		if j >= i {
			j++
		}
		chosen = i
		if lessLoaded(cands[j], cands[i], b.inflight) {
			chosen = j
		}
	}
	ep := cands[chosen]
	b.inflight[ep.InstanceID]++
	released := false
	release := func() {
		b.mu.Lock()
		defer b.mu.Unlock()
		if !released {
			released = true
			if b.inflight[ep.InstanceID] > 0 {
				b.inflight[ep.InstanceID]--
			}
		}
	}
	return ep, release, nil
}

func (b *Balancer) candidatesLocked() []Endpoint {
	if b.localZone == "" {
		return b.endpoints
	}
	local := make([]Endpoint, 0, len(b.endpoints))
	for _, ep := range b.endpoints {
		if ep.Zone == b.localZone {
			local = append(local, ep)
		}
	}
	// With fewer than two local endpoints, P2C has nothing to compare, so
	// include remote zones and let the comparison prefer the idle local one.
	if len(local) >= 2 {
		return local
	}
	return b.endpoints
}

// lessLoaded compares in-flight per unit weight without floating point.
func lessLoaded(a, b Endpoint, inflight map[string]int) bool {
	return inflight[a.InstanceID]*b.Weight < inflight[b.InstanceID]*a.Weight
}

const maxRetryTokens = 10

// RetryBudget is a token bucket refilled by a fixed ratio of every request.
// It caps retries so an outage cannot turn one client call into an unbounded
// retry storm. A production variant may add a small per-second floor.
type RetryBudget struct {
	mu                sync.Mutex
	ratio             float64
	tokens            float64
	requests, retries int
}

// NewRetryBudget validates ratio in (0, 1].
func NewRetryBudget(ratio float64) (*RetryBudget, error) {
	if ratio <= 0 || ratio > 1 {
		return nil, fmt.Errorf("discovery: retry ratio must be in (0,1], got %v", ratio)
	}
	return &RetryBudget{ratio: ratio}, nil
}

// RecordRequest adds one request's share to the retry budget.
func (rb *RetryBudget) RecordRequest() {
	rb.mu.Lock()
	defer rb.mu.Unlock()
	rb.requests++
	rb.tokens += rb.ratio
	if rb.tokens > maxRetryTokens {
		rb.tokens = maxRetryTokens
	}
}

// AllowRetry consumes one token. False means the caller must fail now.
func (rb *RetryBudget) AllowRetry() bool {
	rb.mu.Lock()
	defer rb.mu.Unlock()
	if rb.tokens < 1 {
		return false
	}
	rb.tokens--
	rb.retries++
	return true
}

// Amplification is offered load divided by original requests.
func Amplification(requests, retries int) float64 {
	if requests <= 0 {
		return 0
	}
	return float64(requests+retries) / float64(requests)
}

// LayeredAmplification models retries at several hops: (1+ratio)^layers.
func LayeredAmplification(ratio float64, layers int) float64 {
	if ratio <= 0 || layers <= 0 {
		return 1
	}
	return math.Pow(1+ratio, float64(layers))
}
// file: internal/platform/discovery/balancer_test.go
// tier: L1
// verify: go test ./internal/platform/discovery/ -count=1
package discovery

import "math"
import "testing"

func TestBalancerPrefersLocalZone(t *testing.T) {
	b := NewBalancer("az-a", 11)
	b.Update(Snapshot{Version: 7, Endpoints: []Endpoint{
		{"a-1", "10.0.0.1:8080", "az-a", 1},
		{"a-2", "10.0.0.2:8080", "az-a", 1},
		{"b-1", "10.0.1.1:8080", "az-b", 1},
		{"b-2", "10.0.1.2:8080", "az-b", 1},
	}})
	for i := 0; i < 100; i++ {
		ep, release, err := b.Pick()
		if err != nil {
			t.Fatalf("Pick: %v", err)
		}
		if ep.Zone != "az-a" {
			t.Fatalf("pick %d zone=%s, want az-a", i, ep.Zone)
		}
		release()
	}
}

func TestBalancerP2CAvoidsLoadedEndpoint(t *testing.T) {
	b := NewBalancer("", 5)
	b.Update(Snapshot{Version: 1, Endpoints: []Endpoint{
		{"a", "10.0.0.1:8080", "", 1},
		{"b", "10.0.0.2:8080", "", 1},
	}})
	first, releaseFirst, _ := b.Pick()
	second, releaseSecond, err := b.Pick()
	if err != nil {
		t.Fatalf("second Pick: %v", err)
	}
	if second.InstanceID == first.InstanceID {
		t.Fatalf("second pick=%s, want the idle instance", second.InstanceID)
	}
	releaseFirst()
	releaseSecond()
}

func TestBalancerFallsBackWhenSingleLocal(t *testing.T) {
	b := NewBalancer("az-a", 3)
	b.Update(Snapshot{Version: 2, Endpoints: []Endpoint{
		{"a-1", "10.0.0.1:8080", "az-a", 1},
		{"b-1", "10.0.1.1:8080", "az-b", 1},
		{"b-2", "10.0.1.2:8080", "az-b", 1},
		{"b-3", "10.0.1.3:8080", "az-b", 1},
	}})
	remote := 0
	for i := 0; i < 100; i++ {
		ep, release, err := b.Pick()
		if err != nil {
			t.Fatalf("Pick: %v", err)
		}
		if ep.Zone == "az-b" {
			remote++
		}
		release()
	}
	if remote == 0 {
		t.Fatal("single local endpoint blocked all remote spillover")
	}
}

func TestRetryBudgetRatio(t *testing.T) {
	rb, err := NewRetryBudget(0.25)
	if err != nil {
		t.Fatalf("NewRetryBudget: %v", err)
	}
	allowed := 0
	for i := 0; i < 100; i++ {
		rb.RecordRequest()
		if rb.AllowRetry() {
			allowed++
		}
	}
	if allowed != 25 {
		t.Fatalf("allowed=%d, want 25", allowed)
	}
	if got := Amplification(100, allowed); math.Abs(got-1.25) > 1e-9 {
		t.Fatalf("amplification=%v, want 1.25", got)
	}
}

func TestLayeredAmplification(t *testing.T) {
	if got := LayeredAmplification(0.2, 3); math.Abs(got-1.728) > 1e-9 {
		t.Fatalf("3 layers at 20%%=%v, want 1.728", got)
	}
	if got := LayeredAmplification(0, 3); got != 1 {
		t.Fatalf("zero ratio=%v, want 1", got)
	}
}

代码有三个边界需要说明。第一,lessLoaded 用交叉相乘比较 inflight/weight,避免浮点误差,也避免除零。第二,候选少于两个时直接返回唯一实例,不强行比较。第三,重试预算只按请求比例补充令牌,低流量服务可以加每秒下限,但那会削弱对重试风暴的抑制,需要单独评估。

本机实测

命令如下,环境为 macOS darwin/arm64、Go 1.26.0;同一 module 执行 go test ./... -count=1 -v 时 10 个用例全部通过,下面只截取本节相关输出。原始输出保存在 .local/reports/be-arch-gov/ch03-be-arch-gov-03-2-client-side-load-balancing-retry-amplification.txt。

$ cd .local/checks/be-arch-gov-ch03
$ gofmt -l . && go vet ./... && go test ./internal/platform/discovery/ -count=1 -v -run '^(TestBalancer|TestRetryBudget|TestLayered)'
=== RUN   TestBalancerPrefersLocalZone
--- PASS: TestBalancerPrefersLocalZone (0.00s)
=== RUN   TestBalancerP2CAvoidsLoadedEndpoint
--- PASS: TestBalancerP2CAvoidsLoadedEndpoint (0.00s)
=== RUN   TestBalancerFallsBackWhenSingleLocal
--- PASS: TestBalancerFallsBackWhenSingleLocal (0.00s)
=== RUN   TestRetryBudgetRatio
--- PASS: TestRetryBudgetRatio (0.00s)
=== RUN   TestLayeredAmplification
--- PASS: TestLayeredAmplification (0.00s)
PASS
ok  	archlab/internal/platform/discovery	0.297s

五个用例分别验证:本地有两个实例时 100 次选择全部落在本地;两个实例中一个已被占用时下一次选择会避开它;本地只有一个实例时远端仍能获得流量;100 个请求、25% 预算恰好允许 25 次重试并把放大控制在 1.25;三层 20% 重试的理论放大为 1.728。测试是单进程、固定随机种子,0.00s 不代表真实网络下的选择开销。

常见错误与边界

第一,只换算法,不给重试预算。P2C 能把请求从慢节点移走,但移走的请求如果继续重试,仍会推高整体负载。

第二,忘记释放在途计数。Pick 返回的释放函数必须在 defer 或请求结束路径调用,否则实例的在途数会永久偏高。

第三,把本地优先做成硬约束。本地实例不足两个时不溢出,会把单点风险放大;本地容量规划要按故障域而非平均值计算。

第四,重试预算按“每层独立”配置却不看调用链深度。入口 10% 的预算经过三层仍会放大到 1.33 倍,预算应该按最深层可接受的总放大倒推。

第五,忽略重试与超时的交互。重试预算只限制次数,不限制总时长;没有超时预算时,重试仍会把连接池占满。可以对照 超时预算与调用链 一起设置。

小结

客户端负载均衡不是选一个算法就结束。P2C 用两个候选的在途数快速避开慢节点,局部性用可用区降低延迟,重试预算用比例上限阻止故障放大;三者缺一,都会把局部抖动变成系统性故障。

本节实现把在途计数、本地优先与令牌桶放在同一个包中,并用五个测试覆盖选择、溢出、预算与放大算式。真实环境还要测量重试预算对成功率的实际影响、跨区流量的成本,以及调用链每层的超时配置;示例中的 25% 和 1.728 是模型值,不是生产承诺。

阅读导航:上一节:3.1 服务注册、健康检查与实例生命周期 · 下一节:3.3 服务网格的收益边界:Sidecar、xDS 与运维成本 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查