高级后端 · 分布式韧性与容量规划

retry 是放大器, deadline 是接力棒, 熔断是止损 — 容量永远按"挂 N 台"规划, 不按平均值自欺

Retry Storm: 重试是流量放大器 服务 A 正常 1000 QPS 失败 → retry 3 次 服务 B (变慢) 实收 ≈4000 QPS "只是有点慢" → 被干死 正确 retry 五要素: 有限次数 · 指数退避 · jitter · 只重试幂等 · retry budget Retry Budget: 重试流量 ≤ 正常流量的 10% 成功率越低, 可用重试预算越少 — 自动收敛, 不会雪崩 Deadline 传递: 预算是递减的接力棒 错: 每层都设 2s → A+B+C 最坏 6s, 用户早走了 A 2s B 2s C 2s = 6s! 对: 总预算 2s, 层层继承剩余时间 user 2.0s A 后剩 1.8s B 后剩 1.2s → C 实现: ctx WithTimeout 从入口创建, 整条链共享同一 ctx 语义: 超时/取消沿调用链自动传播, 下游不会白干 注意: 只有一开始的入口才创建新预算, 中间层永远继承 Circuit Breaker: 不要攻击已经生病的下游 Closed 正常放行, 统计错误率 错误率 > 阈值 Open 快速失败, 不打下游 冷却 30s Half-Open 少量探测请求 探测成功 → 恢复 探测失败 → 回 Open 核心目的: 给下游喘息窗口, 把故障从"拖垮全网"压成 "快速失败+降级" 配套: 降级预案 (缓存/默认值/只读) 必须真演练过 容量规划: 按"挂 N 台"算, 不按平均值吹 N-1 冗余推演 10 台: 挂 1 台还能扛? 挂 2 台呢? 比"正常能跑多少"更接近生产 Headroom 水位 常态 70~80% 以内: 留故障/突发/ 发布/GC 余量, 跑满=裸奔 扩容非线性 10 台 1000 QPS ≠ 100 台 10000: DB/Redis/带宽/锁 先挂 HPA 指标选择 IO bound: CPU 20% 但 in-flight 1万 改用 QPS / in-flight / latency 扩 压测四类 Load 正常流量 · Stress 极限 Spike 突发 · Soak 长跑抓泄漏 Coordinated Omission 卡 5s 时压测端没按计划发压, 测出的延迟比真实好看 → open-loop 发布回归对比: QPS/P50/P99/error/CPU-per-req/DB-per-req/cache-hit — 回归常是"每请求+20%", 流量一大才爆

调用链两件武器

  • • timeout: connect/read/write/total 分层显式
  • • deadline 传递: 总预算层层递减, 不层层满额
  • • retry 五要素, 预算封顶 10%
  • • 熔断三态给下游喘息窗口

热点与容量

  • • 热点六源: key/user/partition/shard/row/机器
  • • 分布式最怕"平均好看, 热点快死"
  • • 容量按 N-1 推演, 水位留 headroom
  • • 扩容非线性: DB/Redis 先挂

压测的诚实性

  • • 四类分开: Load/Stress/Spike/Soak
  • • 流量画像真实: 比例/payload/数据量
  • • open-loop 防 coordinated omission
  • • 发布必对比性能基线, 回归即拦截

💡 一句话理解

分布式系统像一个多级 relay 比赛: deadline 是总计时器 — 接力棒递到谁手里, 谁只剩剩余时间, 而不是每人重新发一块满额表; retry 是喊"重来" — 没节制的喊号会让 already 累瘫的下一棒再跑四遍; 熔断是裁判叫停 — 让生病的选手休息而不是继续鞭打。容量规划同理: 考核标准不是"10 个人平时能搬多少砖", 而是"病倒 2 个之后还能不能按期交房" — headroom 不是浪费, 是给事故买的保险。

🧠 必知必会 必考 & 必会

timeout 四分类
connect / read / write / total 各管一段。没有超时的调用是给未来事故存的定期炸弹 — 特别是 connect, 默认可卡 2 分钟。
# 错: client = http.Client{}          # 无任何超时
# 对: Timeout: 2s (total) + Transport 显式分项
deadline 传递
入口创建总预算, 全链共享同一 ctx, 剩余时间自动递减。每层满额 2s × 3 层 = 最坏 6s, 用户 2s 就走了。
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// req.WithContext(ctx) 传给 A→B→C, 剩余预算自动继承
retry storm 数学
B 变慢, A 重试 3 次: 1000 QPS 变 ≈4000 QPS 实打在 B 上。"只是有点慢"的下游被活活打死 — 重试是放大器不是保险丝。
load = 1000; retry = 3
actual = load * (1 + retry)      # → 4000 QPS
# 若 A/B/C 三层各自重试: 指数级放大
正确 retry 五要素
有限次数、指数退避、jitter、只重试幂等操作、retry budget (重试 ≤ 正常流量 10%)。缺一条都可能放大故障。
backoff = base * 2**n + rand(jitter)
# n=0→100ms  n=1→200ms  n=2→400ms ± 50%
# budget: success 越少, 允许的重试越少
熔断三态机
Closed 统计错误率 → 超阈值 Open (快速失败) → 冷却后 Half-Open (少量探测) → 成功恢复/失败回 Open。目的是给下游喘息窗口。
# 阈值示例: 30s 窗口内 错误率 > 50% 且 ≥ 20 个请求
# Open 30s → half-open 放 1/10 探测 → 全对才 Closed
限流四算法
token bucket (匀速+突发)、leaky bucket (整形)、fixed window (便宜但边界突刺)、sliding window (平滑)。限流是容量保护, 不只防攻击。
# token bucket: rate=1000/s burst=2000
# 平时匀速, 允许 2 秒突发 — 匹配真实流量形态
热点六源
热点 key / user / partition / shard / DB row / 机器。分布式最怕"平均值很好看, 热点节点快死了" — 必须按 per-instance/per-key 拆开看。
# pod-A 800 QPS, pod-B/C 各 100 — 总体 QPS 看不出异常
# per-instance 曲线一拆 → A 已经冒烟
平均值骗子
平均 CPU 50% 可能是 A=100%/B=30%/C=20%。所有集群指标都要有 max 与分布视角, 告警按 instance 拆。
# 错: avg(cpu) = 50% → 健康
# 对: max_by_instance(cpu) = 100% → 告警
HPA 指标选择
IO bound 服务 CPU 20% 但 in-flight 10000, CPU HPA 永远不扩。扩容指标要匹配瓶颈: QPS / in-flight / queue depth / latency。
# IO bound: cpu=20%, p99=5s, inflight=10000
# → HPA on cpu: "没事, 不扩"  ← 翻车
# → 改扩容指标: inflight > 2000 per pod
容量非线性
"一台 1000 QPS, 10 台就是 10000" 是谎言: 先挂的可能是 DB、Redis、带宽或锁竞争。容量上限 = 整条链路最短板。
app 能扛 10000; DB 连接池上限 4000
# → 系统容量 = 4000, 不是 10000 (短板决定)
N-1 与 headroom
生产问题不是"正常状态能跑多少", 而是"挂 1 台/2 台还能不能扛"。常态水位 70~80% 以内, 留足故障/突发/发布/GC 余量。
10 台常态 90% → 挂 1 台后 100% → 雪崩
# 正解: 常态 70%, 挂 2 台后 87.5% → 存活
压测四类
Load (预期流量) / Stress (找极限) / Spike (突发) / Soak (长跑数小时抓泄漏与劣化)。只做第一种等于没做。
# Soak 4h 才能暴露: memory leak / conn leak /
# GC 劣化 / 缓存膨胀 — 短压测全是"健康"
coordinated omission
closed-loop 压测端被服务卡住时不再按计划发压, 卡住的请求"缺席"了 — 测出的延迟比真实好看。必须 open-loop 按固定速率发压。
# closed-loop: 收到响应才发下一个 → 卡 5s 期间没发压
# open-loop: 固定 1000/s 发, 记录"应发未发"延迟

🏭 生产实战 real world

场景 1 · deadline 全链改造: 从"层层 2s"到"预算 2s"

网关 2s、服务 2s、下游 2s 的拍脑袋配置, 最坏 6 秒用户早已离开。改造为入口预算制。

// 网关入口: 总预算 2s, 只在这里创建一次
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

// 服务 A: 继承 ctx, 不新建预算; 自身耗 0.2s 后
// B 拿到的剩余 ≈1.8s; C 再剩 ≈1.2s — 层层递减
resp, err := bClient.Call(ctx, req)     // req.WithContext(ctx)
if errors.Is(err, context.DeadlineExceeded) {
    return degrade(ctx)                 // 超时走降级而非干等
}

配套: 每层 timeout 取"下游 P99 × 1.5", 而不是全链统一值。

场景 2 · retry 五要素落地: 退避 + 抖动 + 预算

裸 retry 3 次把下游打挂的真实案例, 改造后重试流量收敛到 8%。

func Call(ctx context.Context, req *Request) (*Resp, error) {
    if !req.Idempotent {         // 非幂等不重试, 防重复下单
        return doCall(ctx, req)
    }
    for attempt := 0; attempt < 3; attempt++ {
        if budget.Exceeded() {    // 重试预算: ≤ 正常流量 10%
            return nil, ErrRetryBudget
        }
        resp, err := doCall(ctx, req)
        if err == nil || !retryable(resp) {
            budget.RecordSuccess()
            return resp, err
        }
        d := 100*time.Millisecond << attempt       // 指数退避
        d += time.Duration(rand.Int63n(int64(d)))  // 抖动防同步重试
        select {
        case <-time.After(d):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, ErrRetriesExhausted
}

上线后 B 服务在 A 的故障演练中存活: 实收流量峰值仅 +8%。

场景 3 · 熔断器接入: 用状态机止损

依赖的第三方图片服务变慢, 全站线程被拖死 — 熔断 + 降级让主链路脱敏。

var cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name: "img-proxy",
    ReadyToTrip: func(c gobreaker.Counts) bool {
        return c.Requests >= 20 &&
            float64(c.TotalFailures)/float64(c.Requests) > 0.5
    },
    Timeout: 30 * time.Second,      // Open 冷却后进 Half-Open
})

func GetImg(url string) ([]byte, error) {
    v, err := cb.Execute(func() (interface{}, error) {
        return fetch(url)          // Open 时直接 ErrOpenState
    })
    if err != nil {
        return placeholderImg, nil  // 降级: 占位图, 主流程不受阻
    }
    return v.([]byte), nil
}

效果: 第三方抖动时主接口 P99 不再被拖累, 恢复后自动闭合。

场景 4 · 令牌桶限流: 保护自己的容量水位

大促流量不可控, 入口限流让"被拒绝的请求"只付出 1ms, 而不是拖垮全站 3 秒。

var limiter = rate.NewLimiter(rate.Limit(8000), 12000)
// 8000 QPS 匀速 + 12000 突发 — 与压测确认的容量对齐

func handler(w http.ResponseWriter, r *http.Request) {
    if !limiter.Allow() {
        w.Header().Set("Retry-After", "1")
        http.Error(w, "over capacity", http.StatusTooManyRequests)
        return                      // 快速失败 < 1ms
    }
    serve(w, r)                     // 放行的请求服务质量稳定
}
# 配套: 429 比例进告警 — 限流触发说明容量到了, 扩容或降级

原则: 限流值来自压测的 N-1 容量, 不是拍脑袋。

场景 5 · per-instance 告警抓单实例异常

总体 P99=200ms 正常, 用户投诉却不断 — pod-3 已经 5 秒 30 万 goroutine。

# 全局 P99 是平均数, 单实例问题必须按 instance 查
$ promql: topk(3, histogram_quantile(0.99,
    sum by (instance) (rate(http_request_duration_seconds_bucket[5m]))))
# pod-1 150ms  pod-2 160ms  pod-3 5000ms  ← 原形毕露

$ kubectl exec pod-3 -- curl -s localhost:6060/debug/pprof/goroutine?debug=1 | head -1
# goroutine profile: total 30000  ← 泄漏

# 治标: 重启 pod-3;  治本: 修泄漏点 (channel 无 reader)
# 防再犯: per-instance P99 & goroutine 数双告警

平均值把事故藏了 6 小时 — 集群指标永远要有 max 视角。

场景 6 · HPA 从 CPU 换成 in-flight: IO bound 服务的正确扩容

CPU 20%、P99 5s、in-flight 10000 — CPU HPA 无动于衷。换指标。

# 改造前: cpu > 70% 才扩 → IO bound 服务永远不触发
# 改造后: KEDA / 自定义指标
apiVersion: autoscaling/v2
spec:
  metrics:
  - pods:
      metric: {name: "http_inflight_requests"}
      target: {type: AverageValue, averageValue: "2000"}
  behavior:
    scaleDown: {stabilizationWindowSeconds: 300}

# in-flight 来自 Little's Law: QPS × latency, 对容量最敏感

上线后流量毛刺 30 秒内完成扩容, P99 不再爬到 5s。

场景 7 · N-1 容量推演表: 把"挂两台"写进容量文档

容量评审不再问"能跑多少", 而是逐档推演存活能力。

# 服务容量推演表 (压测数据)
# 常态流量   3000 QPS   常态水位 60% (5 台等效)
# 挂 1 台    剩余容量 4800 → 水位 62%  ✓ 存活
# 挂 2 台    剩余容量 3600 → 水位 83%  ✓ 存活 (P99 劣化 15%)
# 挂 3 台    剩余容量 2400 → 水位 125% ✗ 必须扩容/限流

# 结论写入 runbook: 挂 2 台以内不 page, 挂 3 台自动缩非核心

从此扩容申请都有推演依据, "感觉不够了"变成数字对话。

场景 8 · Soak 压测 8 小时: 抓出连接池慢泄漏

Load 压测 30 分钟一切正常, 生产第 3 天 OOM — Soak 测试专治"时间才能暴露"的问题。

# 压测方案: 70% GET + 20% POST + 10% 慢查询, 真实 payload
# Soak: 60% 峰值持续 8h, 每 10min 采样:
watch -n 600 'curl -s :6060/debug/pprof/goroutine?debug=0 -o g_$(date +%s).pb.gz; \
              ss -ant | grep -c ESTAB >> estab.log; free -m | tail -1 >> mem.log'

# 结果: ESTAB 5h 起单调上涨 → 连接池 max 之外还有"裸连接"
# 定位: 某报表接口直连 newConn 未归还 → 修复

Soak 抓的都是"短压测健康、生产爆炸"的慢性病。

场景 9 · open-loop 压测: 修正 coordinated omission

closed-loop 压测显示 P99 300ms, 生产却出现 5 秒 — 被卡住的请求缺席了统计。

# 错: closed-loop — 收到响应才发下一个
for {
    send(); wait()          # 服务卡 5s 期间, 发压速率归零
}

# 对: open-loop — 固定速率发压, 与响应无关
#   vegeta: -rate 1000/s -duration 60s (恒速)
#   服务卡顿期间请求照发, 真实延迟被如实记录
#   k6: executor constant-arrival-rate + preAllocatedVUs

重测后 P99 从"虚低的 300ms"现形为 4.8s — 才是用户看到的真相。

场景 10 · 发布性能回归门禁: 拦住"每请求 CPU +20%"

性能回归很少"直接挂", 而是悄悄贵 20%, 流量一大才爆 — 把对比做成发布卡点。

# canary 5% 流量 15 分钟, 自动对比 baseline:
#   P50/P95/P99     ±10% 内放行
#   error rate      绝对值 < 0.1%
#   cpu_per_request ±15%
#   db_queries_per_request  增加即拦截 (N+1 防线)
#   response_bytes  中位数暴涨即拦截 (payload 回归)
# 拦截即告警, 附 trace 链接 — 5 分钟定位到 commit

上线这套门禁后, 两起 payload 膨胀与一起 N+1 在 canary 阶段被拦下。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 每层都设满额 timeout — A/B/C 各 2s, 最坏 6s 用户早走了. 原因: 配置各自为政. 正解: 入口总预算 + deadline 传递。
# 错: gw 2s / a 2s / b 2s
# 对: 入口 2s, ctx 全链继承递减
坑 2 · 重试非幂等操作 — 下单/扣款重试 = 重复扣款. 原因: 无脑统一重试策略. 正解: 只有幂等操作可重试, 写操作用幂等键。
# 错: retry(post /order)
# 对: 请求带 Idempotency-Key + 服务端去重
坑 3 · 重试无退避 — 立即重试 3 次等于 4 倍同步流量. 原因: 只写了次数. 正解: 指数退避 + jitter 防同步重试。
# 错: for i:=0;i<3;i++ { call() }   # 同步炮轰
# 对: 100ms << n + rand jitter
坑 4 · 熔断阈值太灵敏 — 一个错误就 Open, 正常抖动也熔断. 原因: 阈值拍脑袋. 正解: 窗口内错误率 + 最小样本量双条件。
# 错: errors > 1 → open
# 对: requests ≥ 20 且 error_rate > 50%
坑 5 · half-open 全量探测 — 恢复瞬间放全量, 刚爬起来的下游再被打倒. 原因: 状态切换无节流. 正解: half-open 只放小比例探测请求。
# 错: 冷却后立即全量放行
# 对: half-open 放 1/10, 连续成功才全开
坑 6 · 单机限流当集群限流 — 每台 1000 QPS, 20 台实际 2 万, 容量早超. 原因: 部署形态误解. 正解: 集中式配额 (Redis) 或按实例数换算。
# 错: 每实例 limiter 1000, 想"全站 1000"
# 对: redis lua 集群配额 / 1000 × 实例数显式换算
坑 7 · 平均 QPS 判容量 — 峰谷比 10:1 的系统按均值扩容必炸. 原因: 只看平均. 正解: 按 P99 峰值 + 增长率规划。
# 错: 日均 2000 QPS → 扩到 2000
# 对: 峰值 20000 × 大促系数 规划
坑 8 · HPA 只看 CPU — IO bound 服务 CPU 20% 却已 5 秒延迟. 原因: 指标与瓶颈错位. 正解: in-flight/QPS/latency 作为扩容指标。
# 错: hpa targetCPUUtilizationPercentage: 70
# 对: http_inflight_requests > 2000/pod
坑 9 · 热点 key 不拆 — 单 shard 单行 800 QPS, 扩容无解. 原因: 数据分布不均. 正解: 本地缓存 + key 打散 (加随机后缀多副本)。
# 错: 全站库存一行 UPDATE
# 对: 分桶 stock_0..15 + 随机打散
坑 10 · payload 无监控 — 接口响应 10KB→10MB, 一切资源跟着爆. 原因: 只看延迟不看体积. 正解: request/response bytes 进监控与回归门禁。
# 错: 面板只有 P99
# 对: histogram_quantile(bytes) + 中位数告警
坑 11 · 分页无上限 — limit=100000 一发, DB/内存/序列化全连坐. 原因: 参数未校验. 正解: 服务端强制 limit ≤ 100 + cursor。
# 错: limit := r.URL.Query("limit")  # 直接用
# 对: if limit > 100 { limit = 100 }
坑 12 · 压测只打 GET / — 首页能扛不代表下单链路能扛. 原因: 压测偷懒. 正解: 按生产流量画像混合路径与比例。
# 错: ab -n 100000 http://svc/
# 对: 70% GET 列表 + 20% POST 下单 + 10% 大查询
坑 13 · 压测数据量失真 — 空表压出的 P99 到亿行表完全不可信. 原因: 环境造数据省事. 正解: 压测库按生产规模造数 + 索引/统计信息对齐。
# 错: 压测表 1000 行 → P99 5ms → 上线
# 对: 按生产行数 × 分布造数再压
坑 14 · closed-loop 压测虚报 — 卡顿期间发压归零, 延迟被"美化". 原因: coordinated omission. 正解: open-loop 恒速发压。
# 错: 收到响应才发下一个
# 对: vegeta -rate 1000/s 恒速
坑 15 · 容量线性外推 — 10 台 1000 QPS 推 100 台 10000, DB 先挂. 原因: 忽略短板. 正解: 全链路压测找最小短板。
# 错: capacity = per_pod × n
# 对: end-to-end 压测 → min(app, db, redis, bw)
坑 16 · 常态跑满 100% — 没有故障余量, 一台掉线全站连锁. 原因: 成本压到极限. 正解: 常态水位 ≤ 80%, N-1 冗余。
# 错: 10 台常态 95% "资源利用最大化"
# 对: 常态 70%, 挂 2 台 87% 仍存活
坑 17 · 发布不看性能指标 — 每请求 CPU +20% 静默上线, 流量一大才爆. 原因: 只有功能测试. 正解: canary 自动对比性能基线, 回归即拦截。
# 错: 只测"接口能不能跑通"就全量
# 对: canary 15min: P99/cpu-per-req/db-per-req 对比
坑 18 · 降级预案没演练 — 真出事不敢切开关, 熔断形同虚设. 原因: 降级代码"写了没测". 正解: 演练纳入例行, 降级路径有监控。
# 错: 降级开关三年没拨过
# 对: 每季度演练: 关依赖 → 验证降级 → 恢复
坑 19 · timeout 拍脑袋 — 下游 P99 300ms 却设 30s, 故障被放大 100 倍时长. 原因: 与下游 SLA 无关的配置. 正解: timeout ≈ 下游 P99 × 1.5~2。
# 错: 下游 P99 300ms, 本层 timeout 30s
# 对: timeout 600ms + 失败走降级
坑 20 · 演练只走成功路径 — 混沌工程变成"断一个无关紧要的依赖". 原因: 怕疼. 正解: 按爆炸半径分级, 演练核心依赖故障与恢复。
# 错: 演练: 拔掉测试环境的一个边缘插件
# 对: 预发注入 Redis 延迟 2s → 验证熔断/降级/恢复