retry 是放大器, deadline 是接力棒, 熔断是止损 — 容量永远按"挂 N 台"规划, 不按平均值自欺
分布式系统像一个多级 relay 比赛: deadline 是总计时器 — 接力棒递到谁手里, 谁只剩剩余时间, 而不是每人重新发一块满额表; retry 是喊"重来" — 没节制的喊号会让 already 累瘫的下一棒再跑四遍; 熔断是裁判叫停 — 让生病的选手休息而不是继续鞭打。容量规划同理: 考核标准不是"10 个人平时能搬多少砖", 而是"病倒 2 个之后还能不能按期交房" — headroom 不是浪费, 是给事故买的保险。
# 错: client = http.Client{} # 无任何超时 # 对: Timeout: 2s (total) + Transport 显式分项
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() // req.WithContext(ctx) 传给 A→B→C, 剩余预算自动继承
load = 1000; retry = 3 actual = load * (1 + retry) # → 4000 QPS # 若 A/B/C 三层各自重试: 指数级放大
backoff = base * 2**n + rand(jitter) # n=0→100ms n=1→200ms n=2→400ms ± 50% # budget: success 越少, 允许的重试越少
# 阈值示例: 30s 窗口内 错误率 > 50% 且 ≥ 20 个请求 # Open 30s → half-open 放 1/10 探测 → 全对才 Closed
# token bucket: rate=1000/s burst=2000 # 平时匀速, 允许 2 秒突发 — 匹配真实流量形态
# pod-A 800 QPS, pod-B/C 各 100 — 总体 QPS 看不出异常 # per-instance 曲线一拆 → A 已经冒烟
# 错: avg(cpu) = 50% → 健康 # 对: max_by_instance(cpu) = 100% → 告警
# IO bound: cpu=20%, p99=5s, inflight=10000 # → HPA on cpu: "没事, 不扩" ← 翻车 # → 改扩容指标: inflight > 2000 per pod
app 能扛 10000; DB 连接池上限 4000
# → 系统容量 = 4000, 不是 10000 (短板决定)10 台常态 90% → 挂 1 台后 100% → 雪崩
# 正解: 常态 70%, 挂 2 台后 87.5% → 存活# Soak 4h 才能暴露: memory leak / conn leak / # GC 劣化 / 缓存膨胀 — 短压测全是"健康"
# closed-loop: 收到响应才发下一个 → 卡 5s 期间没发压 # open-loop: 固定 1000/s 发, 记录"应发未发"延迟
网关 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", 而不是全链统一值。
裸 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%。
依赖的第三方图片服务变慢, 全站线程被拖死 — 熔断 + 降级让主链路脱敏。
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 不再被拖累, 恢复后自动闭合。
大促流量不可控, 入口限流让"被拒绝的请求"只付出 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 容量, 不是拍脑袋。
总体 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 视角。
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。
容量评审不再问"能跑多少", 而是逐档推演存活能力。
# 服务容量推演表 (压测数据) # 常态流量 3000 QPS 常态水位 60% (5 台等效) # 挂 1 台 剩余容量 4800 → 水位 62% ✓ 存活 # 挂 2 台 剩余容量 3600 → 水位 83% ✓ 存活 (P99 劣化 15%) # 挂 3 台 剩余容量 2400 → 水位 125% ✗ 必须扩容/限流 # 结论写入 runbook: 挂 2 台以内不 page, 挂 3 台自动缩非核心
从此扩容申请都有推演依据, "感觉不够了"变成数字对话。
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 抓的都是"短压测健康、生产爆炸"的慢性病。
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 — 才是用户看到的真相。
性能回归很少"直接挂", 而是悄悄贵 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 阶段被拦下。
# 错: gw 2s / a 2s / b 2s # 对: 入口 2s, ctx 全链继承递减
# 错: retry(post /order) # 对: 请求带 Idempotency-Key + 服务端去重
# 错: for i:=0;i<3;i++ { call() } # 同步炮轰 # 对: 100ms << n + rand jitter
# 错: errors > 1 → open # 对: requests ≥ 20 且 error_rate > 50%
# 错: 冷却后立即全量放行 # 对: half-open 放 1/10, 连续成功才全开
# 错: 每实例 limiter 1000, 想"全站 1000" # 对: redis lua 集群配额 / 1000 × 实例数显式换算
# 错: 日均 2000 QPS → 扩到 2000 # 对: 峰值 20000 × 大促系数 规划
# 错: hpa targetCPUUtilizationPercentage: 70 # 对: http_inflight_requests > 2000/pod
# 错: 全站库存一行 UPDATE # 对: 分桶 stock_0..15 + 随机打散
# 错: 面板只有 P99 # 对: histogram_quantile(bytes) + 中位数告警
limit=100000 一发, DB/内存/序列化全连坐. 原因: 参数未校验. 正解: 服务端强制 limit ≤ 100 + cursor。 # 错: limit := r.URL.Query("limit") # 直接用 # 对: if limit > 100 { limit = 100 }
# 错: ab -n 100000 http://svc/ # 对: 70% GET 列表 + 20% POST 下单 + 10% 大查询
# 错: 压测表 1000 行 → P99 5ms → 上线 # 对: 按生产行数 × 分布造数再压
# 错: 收到响应才发下一个 # 对: vegeta -rate 1000/s 恒速
# 错: capacity = per_pod × n # 对: end-to-end 压测 → min(app, db, redis, bw)
# 错: 10 台常态 95% "资源利用最大化" # 对: 常态 70%, 挂 2 台 87% 仍存活
# 错: 只测"接口能不能跑通"就全量 # 对: canary 15min: P99/cpu-per-req/db-per-req 对比
# 错: 降级开关三年没拨过 # 对: 每季度演练: 关依赖 → 验证降级 → 恢复
# 错: 下游 P99 300ms, 本层 timeout 30s # 对: timeout 600ms + 失败走降级
# 错: 演练: 拔掉测试环境的一个边缘插件 # 对: 预发注入 Redis 延迟 2s → 验证熔断/降级/恢复