Skip to content

稳定性手段:让慢依赖和突发流量止步于有限资源 ​

一个下游接口平时 20 毫秒返回,故障时要等 2 秒。入口流量没有增加,服务却开始耗尽连接、堆积 goroutine,随后所有接口一起超时。问题在于每个请求占用资源的时间变长了;如果客户端再重试,系统还要处理额外的工作。

这篇文章围绕这条故障链讲超时、重试、限流、熔断、降级、舱壁和负载卸除。目标是能回答三个具体问题:请求还能等多久,系统还能接多少工作,已经接下的工作怎样结束。下文的策略组合是设计建议;Go 行为和演示程序另外给出可核对的依据。

先分清各手段控制什么 ​

手段控制的对象触发后做什么需要付出的代价
超时、deadline单次调用或整条请求的等待时间通知取消,停止等待阈值过短会误杀正常慢请求;取消未必停止远端
重试暂时失败后的再次尝试在预算内重新调用增加流量,也可能重复副作用
限流一段时间内的准入量拒绝,或有界等待突发请求可能被拒;多实例配额需要协调
限并发同时占用某资源的工作数满额时拒绝或排队需要确定队列长度、等待时限和公平性
熔断对近期表现异常的依赖的调用暂停调用,稍后少量探测状态切换可能延迟恢复;误判会扩大拒绝范围
降级一次业务响应所做的工作返回更便宜、明确降低质量的结果数据新鲜度、完整性或功能会降低
舱壁隔离不同依赖、租户或业务的资源份额限制故障消耗的范围闲置份额可能不能被其他业务利用
负载卸除当前系统不该继续承受的工作在昂贵步骤前主动拒绝一部分请求确定失败,换取剩余请求完成

限流和限并发尤其容易混淆。每秒允许 100 次调用,如果每次占用 2 秒,仍可能有约 200 次调用同时进行;限并发 20 则直接限制在途工作,却不承诺每秒完成多少次。这个估算要求系统稳定、统计口径一致,不能拿来描述无限增长的队列。Google 的过载设计还提醒:相同 QPS 的查询可能有不同资源成本,不能只盯总请求数。[1][2]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[入口请求及剩余时间] --> B{租户配额和当前负载允许?}
    B -- 否 --> X[快速拒绝并记录原因]
    B -- 是 --> C{该依赖熔断器允许?}
    C -- 否 --> F[业务允许的降级或明确失败]
    C -- 是 --> D{该依赖舱壁有空位?}
    D -- 否 --> F
    D -- 是 --> E[带 deadline 调用并释放资源]
    E --> G{失败可重试且预算足够?}
    G -- 是 --> H[可取消的退避等待]
    H --> C
    G -- 否 --> I[返回结果或明确失败]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[入口请求及剩余时间] --> B{租户配额和当前负载允许?}
    B -- 否 --> X[快速拒绝并记录原因]
    B -- 是 --> C{该依赖熔断器允许?}
    C -- 否 --> F[业务允许的降级或明确失败]
    C -- 是 --> D{该依赖舱壁有空位?}
    D -- 否 --> F
    D -- 是 --> E[带 deadline 调用并释放资源]
    E --> G{失败可重试且预算足够?}
    G -- 是 --> H[可取消的退避等待]
    H --> C
    G -- 否 --> I[返回结果或明确失败]

这是一种“每次实际尝试都经过熔断和舱壁”的组合。退避期间不占舱壁槽位。把熔断放在重试外面,则它看到的是整次逻辑操作的最终结果,内部多个失败会被隐藏。两种统计含义都可以成立,但必须明确选择,并同时记录逻辑请求数、实际尝试数和拒绝数。

超时要覆盖整条路径,取消要有人接住 ​

timeout 是一段时长,deadline 是一个截止时刻。入口如果只剩 300 毫秒,下游不能因为自己配置了 1 秒就再占用 1 秒。Go 的子 context 会继承更早的父 deadline;每层创建 context 后都要及时调用 cancel,释放相关资源。[3]

go
p, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
defer cancel()
c, done := context.WithTimeout(p, 2*time.Hour)
defer done()
pd, _ := p.Deadline()
cd, _ := c.Deadline()
fmt.Println("child inherits earlier deadline:", pd.Equal(cd))

本次 Go 1.27.2 / Linux amd64 实际输出:

text
child inherits earlier deadline: true

子调用没有得到额外的一小时。实际业务还应从剩余时间中留出序列化、返回响应和必要清理的时间;串行调用的总耗时与重试等待都要算入同一预算。并行调用也不是“各给一份无限时间”,最晚返回的必要分支决定整体能否完成。

HTTP 中几个设置分管不同阶段,不能互相顶替:[4]

设置覆盖范围常见误用
http.Client.Timeout建连、重定向、读取响应体的总体时限零值不设总超时;流式长连接未必适合这个总时限
http.NewRequestWithContext把请求生命周期接到调用方 context创建了 context,却调用另一个不带它的请求
net.Dialer.Timeout建立连接以为它也限制了后面的响应读取
Transport.TLSHandshakeTimeoutTLS 握手以为普通 TCP 连接成功就代表握手不会卡住
Transport.ResponseHeaderTimeout请求体完整写出后,等响应头不覆盖响应体读取
Server.ReadHeaderTimeout服务端读请求头不等于业务 handler 的执行时限

超时不是回滚。远端可能已经提交,响应在路上丢失;本地也可能只是停止等待,工作 goroutine 仍在运行。Go 不提供“杀掉任意 goroutine”的 context 机制:被调用方必须检查 Done,或使用接受 context 的 I/O。不要为了给不支持取消的函数套超时就随手另开一个 goroutine,调用返回后那个工作仍可能继续积累。资源所有者要关闭响应体、归还连接、释放信号量;不能把这些寄托在调用方已经放弃之后。[3][4]

重试前,先决定这次失败有没有资格再做一次 ​

网络错误只告诉调用方“没有得到可用结果”,不保证业务没有执行。对创建订单、扣减库存等操作,先提供幂等语义,再讨论重试;同一个逻辑操作复用原幂等键。无效参数、权限不足等确定失败通常要修改请求或处理权限,原样重发没有帮助。HTTP 幂等方法的定义指向预期服务器效果,响应内容和状态码不必相同。[5]

一套可执行的重试决策至少需要这些项:

项要回答的问题
分类哪些错误暂时可恢复?连接失败、特定状态码和业务错误分别怎么处理?
总尝试次数maxAttempts=3 包含首次调用,即最多重试两次
端到端预算还有时间等待并完成下一次调用吗?
重试预算整个客户端或依赖近期允许多少额外尝试?
退避第 k 次等待上限是多少,如何封顶?
抖动多个客户端如何避免同时醒来?
服务端提示接口规定的 Retry-After 等提示如何与总 deadline 协调?

指数退避常写成 min(cap, base × 2^k)。直接位移或相乘会溢出,示例用饱和乘法封顶。Full Jitter 再从 [0, 上限) 抽取等待时长,把原本同时失败的客户端分散开;抖动降低同步重试的风险,不限制额外请求的总数。因而它要和最大尝试次数、deadline、重试预算一起使用。[6]

go
fmt.Println("capped backoff:",
    Backoff(10*time.Millisecond, 80*time.Millisecond, 0),
    Backoff(10*time.Millisecond, 80*time.Millisecond, 1),
    Backoff(10*time.Millisecond, 80*time.Millisecond, 20))

实际输出:

text
capped backoff: 10ms 20ms 80ms

重试预算可以是单位时间最多额外 N 次,也可以是随初始请求补充的令牌预算。它与“每个请求最多三次”不同:后者在大量请求同时失败时仍会放大流量。下面的实验把两枚重试令牌共享给调用器,初始请求不消耗令牌:

go
err := Retry(ctx, 5, NewBudget(2), zeroDelay, transientOperation)
fmt.Println("attempts:", calls, "budget exhausted:", errors.Is(err, ErrBudget))
text
attempts: 3 budget exhausted: true

虽然最大允许五次尝试,实际只有首次加两次重试。文末的 Budget 是一次性计数器,为了把约束看清楚;它没有自动补充、滑动窗口、租户公平性,不能直接充当长期运行的生产预算器。

多层重试还会乘起来:如果五层各最多尝试三次,最底层在极端失败路径可能收到 3^5=243 次尝试。这是调用树的算术上界,不是所有系统必然出现的流量。优先确定由哪一层承担重试,并检查 SDK、代理和队列消费者已有的重试。不要只数自己代码里的循环。[7]

限流算法的差异,在“突发怎么办”上最明显 ​

算法核心状态与动作突发、成本和边界
令牌桶按速率 r 补充令牌,上限 b;一次请求取一个或多个可以立刻使用积攒的 b 个令牌;适合允许短突发;本地桶不自动变成全局配额
漏桶整形有界队列按规定速率出队输出更平滑,但请求增加排队延迟;队列满必须拒绝,不能无限积压
漏桶计量记录随时间“漏出”的占用水位,超水位拒绝可用于准入而不真的排队;不要把它和整形队列的等待语义混写
滑动日志窗口保存最近 W 内每次到达时间,移除过期记录可以精确限制窗口事件数,状态和清理成本随事件量增长
滑动计数窗口用小时间桶,或相邻固定窗加权估计状态较少,但边界精度取决于桶粒度和估计方法
自适应准入观察延迟、在途数、拒绝率等反馈,调整允许量能跟随依赖能力变化;需要冷启动、上下限、平滑和恢复探测,避免反馈振荡

令牌桶可直接看 x/time/rate 的 Allow、Reserve 和 Wait:分别是立即判断、预留未来令牌和在 context 约束下等待。Reserve 后不打算执行,要取消 reservation;否则后续请求可能为一次不存在的工作等候。[8]

滑动窗口针对的是“最近一段时间”,固定窗口针对的是“这个时间格”。例如每分钟限 100 次,固定窗末尾 100 次加下一分钟开头 100 次,可能在极短时间接受 200 次;精确滑动日志不会允许同一 60 秒范围超过 100 次。这个差异是窗口定义直接推导出来的,不能把所有“滑动窗口”实现都当成精确日志。

自适应没有统一算法。Netflix 的 concurrency-limits 根据延迟等信号调整并发限制,控制的是在途数;Google SRE 描述的客户端 adaptive throttling 则根据尝试与接受情况进行本地概率拒绝。两者都利用反馈,却不是同一个算法。选择时要问反馈来自哪里、多久更新、失败时怎么恢复,而不是只看名称。[1][9]

全局配额也需要单独设计。给 N 个实例各配置 100/s,通常得到的只是每实例限制;扩容还会改变合计额度。集中计数可接近统一配额,但增加网络往返和故障依赖;租赁一段额度到本地能减少调用,却要接受租赁过期、闲置额度和短时误差。这里是设计取舍,不承诺任一实现的具体一致性或误差。

熔断要看状态,降级要看业务含义 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 有效样本达到阈值
    Open --> HalfOpen: 冷却时间结束
    HalfOpen --> Closed: 有限探测满足成功条件
    HalfOpen --> Open: 探测失败
    Closed --> Closed: 正常调用并记录结果
    Open --> Open: 快速拒绝
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 有效样本达到阈值
    Open --> HalfOpen: 冷却时间结束
    HalfOpen --> Closed: 有限探测满足成功条件
    HalfOpen --> Open: 探测失败
    Closed --> Closed: 正常调用并记录结果
    Open --> Open: 快速拒绝

这是常见三态模型,具体阈值、统计窗口、探测并发和成功条件由库与配置决定。gobreaker 就提供这些配置入口,不能把某个库的默认值当成熔断定义。[10]

设置错误率阈值时,要一起设置最低样本量,否则一次失败就可能把低流量依赖熔断。还要区分业务拒绝、调用方主动取消、舱壁拒绝和真正的依赖失败。把它们全部计为依赖失败,熔断器可能惩罚错对象。半开时限制探测量,避免恢复瞬间把全部流量重新压上去;熔断器本身也不负责结束已经卡住的调用,超时仍需要保留。

降级发生在业务层。例如推荐接口失败时返回经过授权的缓存列表,可以保持页面可用;余额查询失败时伪造余额为零,会把未知状态伪装成真实数据。每个降级都要声明来源、允许的陈旧时间和缺失字段,并给客户端能识别的语义。缓存也可能故障,降级路径同样需要资源上限,不能在故障时触发另一条更昂贵的查询。[1]

舱壁和负载卸除,让剩余工作有机会完成 ​

假设推荐和支付共享 100 个连接,推荐故障占满后,支付也会失败。给它们独立的连接池、并发槽位或工作队列,就能限制这种资源争用。隔离边界要跟真正的共享资源对齐:只拆两个 Go channel,但下面仍共用一个已耗尽的数据库池,保护仍会在数据库处失效。

本地演示用容量为 1 的 channel 做“无队列舱壁”:

go
select {
case b <- struct{}{}:
    defer func() { <-b }()
default:
    return ErrBusy
}
return fn(ctx)

实际输出:

text
second request rejected: true
slot released: true

第一次调用占着槽位时,第二次直接失败;第一次结束后槽位可再次使用。defer 放在成功取得槽位之后,所以正常返回和 panic 展开都释放它。测试另验证了 panic 路径,没有用一个永不结束的 goroutine 来模拟泄漏。

负载卸除通常放在昂贵解析、数据库访问或大量内存分配之前,参考在途数、队列等待时间、CPU 等信号拒绝低优先级工作。如果请求到达时已经几乎没有完成所需时间,继续排队只是消耗资源。拒绝本身也有成本,因此入口、客户端和依赖侧需要协同,不能假设“全部返回 503”就完全没有压力。[1][2]

生产接入时,把“排队已满”“重试预算耗尽”“熔断拒绝”“调用超时”“返回降级结果”分开记录。总错误率可以告诉人系统正在出问题,分开的原因才能告诉人应该减流、修依赖、调预算,还是撤销一次不合适的降级。

验证范围与尚未证明的事 ​

本次只运行了文末的本地 Go 实验:父子 deadline、有限重试预算、退避封顶、并发消费预算、取消路径和舱壁资源释放。go test、go test -race、go vet 均通过。没有对公网压测,没有部署分布式限流器,也没有测出生产阈值。取消检查与实际执行之间仍可能并发发生取消;context 是协作协议,并非原子地撤销外部副作用。

依据 ​

  1. Google SRE:Handling Overload,Client-Side Throttling、Criticality、Utilization Signals:成本差异、反馈拒绝、降级与优先级。
  2. Google SRE:Addressing Cascading Failures,Preventing Server Overload、Queue Management:过载传播、有界队列和负载卸除。
  3. Go 1.27.2 context 源码及文档:WithDeadline、WithCancel、取消资源;本机 go doc 交叉核验。
  4. Go 1.27.2 HTTP client、transport、server、Dialer:各超时范围、请求 context 和响应体责任。
  5. RFC 9110 §9.2.2、§10.2.3:幂等方法、自动重试边界与 Retry-After。
  6. AWS Architecture Blog:Exponential Backoff and Jitter:封顶退避、Full Jitter 的机制。
  7. AWS Builders' Library:Timeouts, retries, and backoff with jitter:副作用、重试放大、重试限额的背景。
  8. golang.org/x/time/rate 文档:令牌桶与三个准入入口。固定版本实测见本节工具选型文章。
  9. Netflix concurrency-limits 官方仓库:自适应并发限制的设计说明;此处不承诺特定版本默认参数。
  10. sony/gobreaker v2 文档:三态及配置接口;固定版本实测见工具选型文章。

完整可复现实验 ​

以下是全部文件。程序只使用标准库,没有外部服务。zeroDelay 等正文名称是阅读用缩写,完整程序中的调用采用等价的内联函数。随机抖动测试只验证范围,不要求每次产生同一序列。

sh
go version
go run .
go test -v ./...
go test -race ./...
go vet ./...

go.mod ​

go
module example.com/stability

go 1.27.0

main.go ​

代码块 · 135 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand/v2"
	"sync/atomic"
	"time"
)

var ErrBusy = errors.New("bulkhead full")
var ErrBudget = errors.New("retry budget exhausted")
var ErrTransient = errors.New("transient")

type Budget struct{ remaining atomic.Int64 }

func NewBudget(n int64) *Budget { b := &Budget{}; b.remaining.Store(n); return b }
func (b *Budget) Take() bool {
	for {
		n := b.remaining.Load()
		if n <= 0 {
			return false
		}
		if b.remaining.CompareAndSwap(n, n-1) {
			return true
		}
	}
}

// Backoff uses saturating multiplication: the duration never wraps around.
func Backoff(base, cap time.Duration, retry int) time.Duration {
	if base <= 0 || cap <= 0 {
		return 0
	}
	d := min(base, cap)
	for i := 0; i < retry && d < cap; i++ {
		if d > cap/2 {
			return cap
		}
		d *= 2
	}
	return d
}
func FullJitter(d time.Duration) time.Duration {
	if d <= 0 {
		return 0
	}
	return time.Duration(rand.Int64N(int64(d)))
}
func Wait(ctx context.Context, d time.Duration) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-t.C:
		return ctx.Err()
	}
}

// attempts includes the initial attempt. The caller supplies a safe-to-retry operation.
func Retry(ctx context.Context, attempts int, b *Budget, delay func(int) time.Duration, fn func(context.Context) error) error {
	if attempts < 1 {
		return errors.New("attempts must be positive")
	}
	var err error
	for i := 0; i < attempts; i++ {
		if e := ctx.Err(); e != nil {
			return e
		}
		if i > 0 {
			if !b.Take() {
				return errors.Join(ErrBudget, err)
			}
			if e := Wait(ctx, delay(i-1)); e != nil {
				return e
			}
		}
		if e := ctx.Err(); e != nil {
			return e
		}
		err = fn(ctx)
		if err == nil || !errors.Is(err, ErrTransient) {
			return err
		}
	}
	return err
}

type Bulkhead chan struct{}

func NewBulkhead(n int) Bulkhead {
	if n < 1 {
		panic("positive capacity required")
	}
	return make(Bulkhead, n)
}
func (b Bulkhead) Try(ctx context.Context, fn func(context.Context) error) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	select {
	case b <- struct{}{}:
		defer func() { <-b }()
	default:
		return ErrBusy
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	return fn(ctx)
}
func main() {
	p, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
	defer cancel()
	c, done := context.WithTimeout(p, 2*time.Hour)
	defer done()
	pd, _ := p.Deadline()
	cd, _ := c.Deadline()
	fmt.Println("child inherits earlier deadline:", pd.Equal(cd))
	calls := 0
	err := Retry(context.Background(), 5, NewBudget(2), func(int) time.Duration { return 0 }, func(context.Context) error { calls++; return ErrTransient })
	fmt.Println("attempts:", calls, "budget exhausted:", errors.Is(err, ErrBudget))
	b := NewBulkhead(1)
	_ = b.Try(context.Background(), func(ctx context.Context) error {
		fmt.Println("second request rejected:", errors.Is(b.Try(ctx, func(context.Context) error { return nil }), ErrBusy))
		return nil
	})
	fmt.Println("slot released:", b.Try(context.Background(), func(context.Context) error { return nil }) == nil)
	fmt.Println("capped backoff:", Backoff(10*time.Millisecond, 80*time.Millisecond, 0), Backoff(10*time.Millisecond, 80*time.Millisecond, 1), Backoff(10*time.Millisecond, 80*time.Millisecond, 20))
}

main_test.go ​

代码块 · 73 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"errors"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

func TestBudgetConcurrent(t *testing.T) {
	b := NewBudget(5)
	var got atomic.Int64
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() {
			if b.Take() {
				got.Add(1)
			}
		})
	}
	wg.Wait()
	if got.Load() != 5 {
		t.Fatal(got.Load())
	}
}
func TestCancellationAndPermanent(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	calls := 0
	f := func(context.Context) error { calls++; return ErrTransient }
	err := Retry(ctx, 3, NewBudget(2), func(int) time.Duration { return 0 }, f)
	if !errors.Is(err, context.Canceled) || calls != 0 {
		t.Fatal(err, calls)
	}
	p := errors.New("invalid argument")
	err = Retry(context.Background(), 3, NewBudget(2), func(int) time.Duration { return 0 }, func(context.Context) error { calls++; return p })
	if err != p || calls != 1 {
		t.Fatal(err, calls)
	}
}
func TestCancelDuringWait(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	calls := 0
	err := Retry(ctx, 3, NewBudget(2), func(int) time.Duration { cancel(); return time.Hour }, func(context.Context) error { calls++; return ErrTransient })
	if !errors.Is(err, context.Canceled) || calls != 1 {
		t.Fatal(err, calls)
	}
}
func TestPanicReleasesSlot(t *testing.T) {
	b := NewBulkhead(1)
	func() {
		defer func() {
			if recover() == nil {
				t.Fatal("missing panic")
			}
		}()
		_ = b.Try(context.Background(), func(context.Context) error { panic("test") })
	}()
	if err := b.Try(context.Background(), func(context.Context) error { return nil }); err != nil {
		t.Fatal(err)
	}
}
func TestJitterAndCap(t *testing.T) {
	for i := 0; i < 1000; i++ {
		d := Backoff(time.Millisecond, time.Second, i)
		j := FullJitter(d)
		if d > time.Second || j < 0 || j >= d {
			t.Fatal(d, j)
		}
	}
}

保留各书来源与许可 · 示例运行条件以正文为准