跳到主要内容

Go 高并发实战(4):背压、限流、隔离与重试风暴

Rainy
雨落无声,代码成诗 —— 致力于技术与艺术的极致平衡
Rainy
8 MIN READ... VIEWS

零拒绝不是稳定性目标。系统超过容量时,及时拒绝一部分请求,才能保护已接纳请求和故障恢复能力。

本期专门处理“流量大于处理能力”。我们把限速、限并发、队列、重试和熔断放进同一个反馈系统,而不是堆叠互不理解的中间件。

一、过载为什么会自我放大

无限队列把拒绝变成超时:请求在内存里等待 2 秒,真正执行时 deadline 已经所剩无几,最终既消耗资源又无法成功。

二、四种控制解决不同问题

控制限制对象适用场景失败动作
速率限制单位时间到达数API 配额、公平性429
并发限制同时在途数保护 CPU/下游短等后 429/503
有界队列等待进入执行的任务吸收短毛刺满时拒绝/降级
熔断已故障的依赖减少无意义调用快速失败/回退

令牌桶允许短 burst,漏桶让输出更平滑;它们都不能替代并发限制。100 QPS 的接口若每次从 10 ms 变成 10 s,仍可能积累 1,000 个在途请求。

三、带等待预算的并发限制

func withAdmission(next http.Handler, limit Limiter, wait time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), wait)
defer cancel()
started := time.Now()

if !limit.Acquire(ctx) {
admissionRejected.Inc()
w.Header().Set("Retry-After", "1")
http.Error(w, "overloaded", http.StatusTooManyRequests)
return
}
admissionWait.Observe(time.Since(started).Seconds())
defer limit.Release()
next.ServeHTTP(w, r)
})
}

准入等待必须远小于总 deadline。记录等待直方图比只记录“当前 in-flight”更早暴露饱和。

四、队列上限来自等待预算

queue capacity ≈ service rate × allowed queue wait

worker 每秒处理 20,000 个 job,允许最多排队 50 ms,队列从约 1,000 起压测。每个 job 若保留 8 KiB,100,000 长度意味着近 800 MiB 业务对象,还没有计算 GC 和 goroutine。

满队列策略必须按业务定义:

  • 在线同步请求:快速失败;
  • 可降级读取:返回缓存/部分结果;
  • 低优先级事件:采样或丢弃并计数;
  • 不能丢的异步任务:写入具备持久化与消费确认的消息系统。

五、Bulkhead:按故障域隔离

type Bulkheads struct {
Search Limiter
Profile Limiter
Billing Limiter
}

一个共享全局池容易被热点接口占满。至少按以下维度评估隔离:关键/非关键流量、不同下游、在线/离线任务、付费/免费租户。隔离过细又会降低资源利用率,可以保留小型共享池处理突发,但必须防止一个租户独占。

六、重试预算与退避

只有同时满足才重试:错误可重试、操作幂等、剩余 deadline 足够、重试预算未耗尽。

func backoff(base, cap time.Duration, attempt int, random *rand.Rand) time.Duration {
maximum := base << min(attempt, 10)
if maximum > cap {
maximum = cap
}
return time.Duration(random.Int63n(int64(maximum) + 1))
}

使用 full jitter 避免所有客户端在整秒边界同时重试。把额外尝试控制成原始请求的一小部分,例如重试预算 5%;依赖错误率升高时应减少重试,而不是固定每次三连击。

6.1 幂等性

  • GET 通常可重试,但仍要考虑昂贵查询;
  • 写操作需要 idempotency key、唯一约束或业务版本号;
  • 超时只代表没收到响应,不代表服务端没成功;
  • hedge request 只能用于幂等读,并受额外并发预算控制。

七、熔断器不是错误率开关

状态机通常为:Closed → Open → Half-Open。需要定义:

  • 统计窗口与最小样本;
  • 哪些错误计入失败,429 是否代表本方过载;
  • Open 多久后允许多少探测;
  • fallback 是否会把压力转移到另一个依赖;
  • 多副本独立熔断是否会同时半开形成探测洪峰。

优先保证 timeout、并发限制和重试预算正确,再引入熔断。错误熔断配置会隐藏真实故障或产生振荡。

八、自适应并发:把延迟当成拥塞信号

固定并发适合容量稳定的依赖;云数据库、共享 API 和突发 CPU 配额的可用容量会变化。自适应控制可以观察短周期 latency/queue wait:无排队且成功率健康时缓慢增加,出现超时或延迟梯度上升时快速降低。

healthy: limit = limit + small_step
congested: limit = max(min_limit, limit × decrease_factor)

这是控制环,不是“根据 CPU 每秒改配置”:需要最小样本、平滑窗口、上下界和冷却时间,防止多个副本同步振荡。上线前用阶梯慢依赖验证收敛。

九、优先级与公平性

单 FIFO 队列会让 10 秒离线导出堵住 50 ms 在线查询。可使用独立队列/worker、加权调度或保留容量:

type PriorityPools struct {
Online Limiter
Batch Limiter
}

按租户限流要防止高基数状态无限增长;令牌桶条目设置 TTL/上限。公平不是所有租户绝对相同,而是策略可解释且不能被单租户耗尽全局资源。

十、重试放大计算

三层调用链每层最多重试 3 次,最坏下游尝试不是 3,而是 3 × 3 × 3 = 27。重试应尽量放在最了解幂等性和剩余 deadline 的单一层。

记录 attemptoriginal_request_id、重试原因和剩余预算。若第一次尝试已耗费 180 ms,而总 deadline 200 ms,第二次尝试大概率只制造更多负载。

十一、缓存降级的陷阱

  • fallback cache 也可能被击穿,必须独立限流;
  • stale 数据要携带时间戳和业务可接受范围;
  • 热点 key 过期会形成 cache stampede,用 singleflight、随机 TTL 和后台刷新;
  • negative cache 防止不存在 key 反复打数据库,但 TTL 不宜过长;
  • 降级响应要在 header/body 标识,不能伪装成新鲜完整数据。

十二、可观测的 Load Shed 响应

拒绝必须携带足够信息,但不能泄漏内部容量:

func rejectOverload(w http.ResponseWriter, reason string) {
overloadRejected.WithLabelValues(reason).Inc()
w.Header().Set("Content-Type", "application/problem+json")
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.NewEncoder(w).Encode(map[string]any{
"type": "https://example.com/problems/overloaded",
"title": "service temporarily overloaded",
"status": http.StatusTooManyRequests,
})
}

reason 只能来自固定枚举如 global_limit/db_bulkhead/tenant_rate,避免高基数。客户端收到 429 后遵守 Retry-After,再加 jitter;网关与服务端要统一谁负责限流,防止重复排队。

十三、韧性组件的组合顺序

推荐请求路径:全局/租户速率 → 入口并发 → 依赖 bulkhead → 单次 timeout → 受预算重试 → 熔断统计。错误顺序会导致重试绕过限流、熔断把本地拒绝算成依赖失败,或 timeout 不包含 queue wait。

为每个组件写一条“它保护谁”和“失败后返回什么”。如果两个组件答案完全相同,可能是重复保护;如果没有组件保护数据库连接池,说明存在空白。

一次请求可以被全局准入接受,却被某个依赖 bulkhead 拒绝。optional 依赖应降级,而不是误记成下游 500。重试必须重新获取 bulkhead 许可,不能绕过容量控制。

13.1 用数字推演一次雪崩

正常:2,000 RPS × 3 fan-out × 30 ms = 180 个下游 in-flight。故障时延迟变成 600 ms,若不限并发,在途调用变为 3,600;客户端平均再重试一次,接近 7,200。连接池只有 500,其余任务全部等待并占用内存。

如果下游 bulkhead 固定为 300、等待 15 ms 后拒绝,系统最多让 300 个调用压到依赖;optional 调用降级,required 调用返回明确 503。错误率会升高,但核心资源有界,恢复时不会有数千个陈旧请求继续冲击下游。

13.2 为什么熔断不能代替并发限制

熔断要积累样本后才 Open,在窗口形成前流量已经进入依赖;慢而成功的调用也可能不计为失败,却照样耗尽连接。并发限制先约束在途资源,熔断再减少已知故障期间的无效尝试,两者解决的问题不同。

十四、过载验收实验

固定到达率逐级增加到设计容量的 200%,同时把下游 P99 从 20 ms 注入到 500 ms:

  • 429/503 按设计上升;
  • 已接纳请求 P99 不发生数量级恶化;
  • in-flight、queue、连接、goroutine、RSS 形成平台;
  • 重试流量不超过预算;
  • 下游恢复后熔断和队列在明确时间内恢复。

上一篇:Go-3:生产 HTTP、RPC 与数据库并发 · 下一篇:Go-5:锁、Atomic 与并发正确性

Logo
RainLib

探索技术、设计与分布式系统的边界。构建面向未来的开发者工具。

留言与建议

© 2026 RainLib. 为未来构建。(Built for the Future)
版权所有。
系统正常