一次重试把 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 与运维成本 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。