主题
稳定性手段:让慢依赖和突发流量止步于有限资源
一个下游接口平时 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[返回结果或明确失败]
这是一种“每次实际尝试都经过熔断和舱壁”的组合。退避期间不占舱壁槽位。把熔断放在重试外面,则它看到的是整次逻辑操作的最终结果,内部多个失败会被隐藏。两种统计含义都可以成立,但必须明确选择,并同时记录逻辑请求数、实际尝试数和拒绝数。
超时要覆盖整条路径,取消要有人接住
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.TLSHandshakeTimeout | TLS 握手 | 以为普通 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: 快速拒绝
这是常见三态模型,具体阈值、统计窗口、探测并发和成功条件由库与配置决定。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 是协作协议,并非原子地撤销外部副作用。
依据
- Google SRE:Handling Overload,Client-Side Throttling、Criticality、Utilization Signals:成本差异、反馈拒绝、降级与优先级。
- Google SRE:Addressing Cascading Failures,Preventing Server Overload、Queue Management:过载传播、有界队列和负载卸除。
- Go 1.27.2 context 源码及文档:
WithDeadline、WithCancel、取消资源;本机go doc交叉核验。 - Go 1.27.2 HTTP client、transport、server、Dialer:各超时范围、请求 context 和响应体责任。
- RFC 9110 §9.2.2、§10.2.3:幂等方法、自动重试边界与 Retry-After。
- AWS Architecture Blog:Exponential Backoff and Jitter:封顶退避、Full Jitter 的机制。
- AWS Builders' Library:Timeouts, retries, and backoff with jitter:副作用、重试放大、重试限额的背景。
- golang.org/x/time/rate 文档:令牌桶与三个准入入口。固定版本实测见本节工具选型文章。
- Netflix concurrency-limits 官方仓库:自适应并发限制的设计说明;此处不承诺特定版本默认参数。
- 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.0main.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)
}
}
}