Go 高并发实战(4):背压、限流、隔离与重试风暴
零拒绝不是稳定性目标。系统超过容量时,及时拒绝一部分请求,才能保护已接纳请求和故障恢复能力。
本期专门处理“流量大于处理能力”。我们把限速、限并发、队列、重试和熔断放进同一个反馈系统,而不是堆叠互不理解的中间件。
一、过载为什么会自我放大
无限队列把拒绝变成超时:请求在内存里等待 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 的单一层。
记录 attempt、original_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 与并发正确性。