系统架构 · 容错弹性与稳定性

挂了怎么继续跑: 超时止损 → 退避重试 → 熔断快速失败 → 舱壁隔离 → 降级兜底, 把"单点慢全站塌"改写成"局部失败整体存活"

容错弹性 · 熔断三态 × 超时预算 × 事故链 vs 破局链 超时止损 → 退避重试 → 熔断快速失败 → 舱壁隔离 → 降级兜底 ① 熔断器三态状态机 (Circuit Breaker) — 错误率超阈值拉闸, 冷却后半开探测 错误率 ≥ 50% (ReadyToTrip) 冷却 30s 到点 (Timeout) 探测全过 → 闭合 探测失败 被拒请求 业务请求进来 Closed 闭合 正常放行 · 滑窗统计错误率 Interval 10s · 近 20 次滑动窗口 Open 熔断打开 请求立即被拒 → 走 fallback Half-Open 半开 同时只放 3 个探测请求 探测样本要小而稳, 偶发慢别翻盘 Fallback 兜底仓 缓存旧值 / 默认值 / 排队稍后 sony/gobreaker v1: Settings{Name, MaxRequests:3, Interval:10s, Timeout:30s, ReadyToTrip} · Execute() 在 Open 态返回 ErrOpenState 即走 fallback ② 调用链超时预算递减树 — 预算从入口 2s 开始, 每层截留自留份额后递减 预算 1.5s 预算 0.8s 预算 0.4s SQL 0.3s GET 0.15s API 网关 总预算 2000ms 订单服务 自留 500ms · 传 1500ms 库存服务 预算 800ms · SQL ≤ 300ms MySQL 慢查询超 300ms 即中断 营销服务 预算 400ms · 与库存并行 Redis 缓存 GET ≤ 150ms · miss 回源 ✗ 反例: 四层各配 3s 独立超时 — 最坏叠到 12s; 入口 2s 已放弃, 下游线程仍在傻等, 连接被白占 → 池子抽干 ✓ 正解: 子预算 = 父预算 − 父自身处理耗时, gRPC/context 会把剩余 deadline 透传给下游 ③ 事故链 vs 破局链 — rose 是没有保护的连锁塌方, emerald 是三件套的挂而不倒 下游 B 变慢 RT 50ms → 30s 无超时 + 盲重试×3 QPS ×4 打回下游 线程/连接池抽干 全部卡在 read 上游 A 雪崩 新请求进不来 级联故障 整站 503 事故现场: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms. Get "http://inventory-svc:8080/stock": context deadline exceeded (Client.Timeout exceeded while awaiting headers) × 12000/s 超时沿链递减 2s→0.8s→0.3s 入口放弃即全链放弃 退避 + 抖动重试, 预算 ≤10% 只重试幂等的读与可重试错误 熔断快速失败 + 半开探测 错误率 50% 拉闸, 冷却 30s 降级兜底 缓存旧值 / 默认值, 挂而不倒

机制视角 — 五件一套的免疫系统

  • • 超时是预算: 不给超时就是把线程押给人质
  • • 重试是放大器: 必须配退避、抖动和预算
  • • 熔断是电闸: 错误率超阈值自动拉闸快速失败
  • • 舱壁是防水隔间: 池子不共享, 烧只烧一间
  • • 降级是预案: 平时演练, 战时一键切换

行为视角 — 故障的传染与阻断

  • • 慢比挂更可怕: 无超时的慢会慢慢抽干线程池
  • • 故障沿调用链反向传染, 每一跳都要装断路器
  • • 熔断打开不是终点: 半开探测决定能否闭环恢复
  • • 假冗余最贵: 同机同柜的多副本等于单点

生产价值 — 平时白养, 战时保命

  • • 混沌演练是证明预案有效, 而不是祈祷它有效
  • • 熔断器状态必须进监控, 打开超 1 分钟即告警
  • • 自愈 = 探针 + 幂等重启, K8s 白送一半
  • • 全链路超时预算是压测出来的, 不是抄来的

💡 一句话理解

把微服务想成一栋楼: 超时是"最多等你 2 秒"的门规, 重试是敲门再试但会吵醒全楼, 熔断是跳闸的电闸, 舱壁是船底的防水隔间, 降级是火灾时关掉广告牌保住消防通道。单机时代一个 try/catch 就够, 分布式时代失败是常态, 真正的问题是"失败之后系统怎么继续跑"。

机制本质: 用快速失败代替无限等待, 用指数退避防止重试踩踏, 用错误率统计自动拉闸, 用资源隔离把爆炸半径锁进一个舱室, 用预案兜底把"报错"翻译成"次好的答案"。五招缺一, 级联故障 (Cascading Failure) 就能把一个下游 500ms 的抖动放大成全站 503。

🧠 必知必会 必考 & 必会

Timeout 超时
不设超时的调用 = 把线程无限期押给对方: 对方一慢, 你的线程池陪葬。超时必须显式声明, 且覆盖"拨号 + 等待 + 读 body"全程。
cli := &http.Client{Timeout: 800 * time.Millisecond}
_, err := cli.Get("http://inventory-svc/stock")
// 关键: 覆盖拨号到读完 body 全程, 到点立刻止损
// → Get "http://inventory-svc/stock": context deadline exceeded
//   (Client.Timeout exceeded while awaiting headers)
超时预算递减
下游的超时 = 上游剩余预算 − 自己的耗时, 不是每层抄同一个 3s。gRPC 的 deadline、Go 的 context 会把"剩余时间"随请求透传, 上游放弃时全链一起放弃。
ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
out, err := inventoryClient.Query(ctx, req) // gRPC: 剩余 deadline 透传
// 关键: 下游最多用 1.5s, 不是自己配置里的 3s
// → 超时后上游立即收到 DeadlineExceeded, 不再傻等
Exponential Backoff 退避
每次重试间隔翻倍 (100ms→200ms→400ms…), 给过载的下游喘息窗口; 配上限 cap 防止间隔爆炸。退避是"给着火的房子让出风口", 不是拖延。
base := 100 * time.Millisecond
d := base << attempt        // 100ms → 200ms → 400ms → 800ms
if d > 5*time.Second { d = 5 * time.Second } // cap 封顶
// 关键: 过载时立刻重试 = 往着火的房子浇油
// → 间隔序列: 100 200 400 800 1600 (ms)
Jitter 抖动
一千个客户端同时超时、又同时重试, 下游每隔 N 秒挨一次齐射。抖动把重试时刻随机化, 齐射摊成噪声, 下游恢复得更快。
d := base << attempt
sleep := time.Duration(rand.Int63n(int64(d))) // Full Jitter: [0,d) 均匀
// 关键: 无抖动时 1w 个客户端会在同一毫秒一起重试
// → 重试齐射被摊成均匀噪声, 下游恢复更快
Retry Budget 重试预算
重试请求占总请求的比例封顶 (常见 10%)。没有预算时, 下游越抖重试越多, 真实流量反被挤掉 — 重试是放大器, 预算是限幅器。
allow := retried*10 <= total      // 已重试占比 ≤ 10% 才放行
// 关键: 把重试量钉在总量一成, 故障不被重试放大
// → total=1000, retried=99 → true; retried=101 → false
Circuit Breaker 熔断
错误率超阈值自动"拉闸": 一段时间内直接拒绝请求、不打下游, 用快速失败保护自己也保护下游。sony/gobreaker、Resilience4j、Envoy 都是同一副状态机骨架。
_, err := cb.Execute(func() (interface{}, error) { return callInv(ctx) })
if errors.Is(err, gobreaker.ErrOpenState) {
    return fallback(ctx) // 关键: Open 态不碰下游, 直接走兜底
}
// → 错误率过阈值后, Execute 立即返回 ErrOpenState
Half-Open 半开探测
冷却结束不能一口气全量放行 — 先放 N 个探测请求试水, 全过才闭合。探测要挑轻量稳定的路径, 否则一个偶发慢请求就把熔断器再次打开, 来回横跳。
gobreaker.Settings{
    MaxRequests: 3,                // 半开同时放 3 个探测请求
    Timeout:     30 * time.Second, // 关键: Open → Half-Open 冷却时长
    Interval:    10 * time.Second, // Closed 态错误率统计窗口
}
// → 冷却 30s 后放 3 个探测, 全过回 Closed, 有失败回 Open
Bulkhead 舱壁隔离
船底分舱, 一个舱进水不沉全船: 每类下游配独立线程池/信号量。共享一个池时, 一个慢下游就能烧光全部线程, 拖死所有无关业务。
resilience4j.bulkhead.instances.pay-core:
  max-concurrent-calls: 50       # 支付核心独占 50 并发
resilience4j.bulkhead.instances.report:
  max-concurrent-calls: 10       # 报表 10, 挤爆只烧自己
# 关键: 池不共享, 报表挂死支付照常
# → 报表打满只拒绝报表请求, 支付池不受影响
Fallback 兜底
熔断/降级触发后返回"次好答案": 缓存旧值、默认值、空列表。兜底必须返回用户可接受的数据, 而不是把异常换皮再抛上去。
@CircuitBreaker(name = "rec", fallbackMethod = "recFallback")
List<Ad> recommend(long uid) { return adRpc.list(uid); }
List<Ad> recFallback(long uid, CallNotPermittedException e) {
    return hotBoard.get(uid); // 关键: 返回旧榜单, 不抛 500
}
// → 熔断期用户看到旧推荐, 而不是白屏
Graceful Degradation 降级
提前把功能分级 (核心交易 / 非核心推荐评论), 故障或大促时主动关掉非核心, 保住主干。降级开关要平时演练, 不能等故障来了现写现配。
degrade:
  recommend: cache   # 推荐降级读缓存旧值
  comment:   off     # 评论: 直接下线不展示
  pay:       full    # 支付全量 — 核心链路不动
# 关键: 开关提前配好并演练, 故障时一键切换
Cascading Failure 级联故障
一个下游变慢 → 上游线程耗尽 → 上游的上游也耗尽 → 故障沿调用链反向传染, 局部故障变全站事故。超时 + 熔断就是给传染链的每一跳装断路器。
// B 卡死 → A 的 200 个 Tomcat 线程全停在 socketRead0
// → A 无法响应, A 的上游跟着超时, 传染两层
java.sql.SQLTransientConnectionException: HikariPool-1 -
  Connection is not available, request timed out after 30000ms.
// 关键: 没有超时/熔断, 故障必然沿链反向传染
Redundancy N+1 冗余
N 个副本干活 + 1 份待命, 本质是"打挂任何一个, 剩余容量仍扛得住峰值"。冗余必须配反亲和: 全排在同一台机器上的 3 副本是假冗余。
replicas: 4                     # 3 副本干活 + 1 份冗余
topologySpreadConstraints:
  - topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
# 关键: 打散宿主机, 同机的多副本是假冗余
# → 任一宿主机故障, 剩余节点仍扛得住
Self-healing 自愈
K8s 探针是自愈的地基: liveness 失败重启容器, readiness 失败摘出负载均衡。自愈动作要幂等且快速, 否则"救火队"变"纵火犯"。
livenessProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10
  failureThreshold: 3           # 连续 3 次失败才动手
# 关键: 重启要快且幂等, 别把正在恢复的实例杀掉
# → 容器假死 30s 内被探测发现并重启拉起
Chaos Engineering 混沌工程
主动注入受控故障 (杀 pod、断网、打满磁盘), 验证降级预案真的能落地。混沌的产出不是"搞挂系统", 而是"证明预案有效"或"提前暴露预案是摆设"。
apiVersion: chaos-mesh.org/v1alpha1
kind: PodChaos                  # Chaos Mesh: 杀 pod 验证自愈
spec:
  action: pod-kill
  mode: fixed-percent
  value: "30"                   # 关键: 杀 30%, 验证剩余容量兜不兜得住
# → 演练报告: 自愈耗时 47s, 熔断 31s 内打开

🏭 生产实战 real world

场景 1 · 库存 RPC 挂 40 分钟, 下单链路只跌了 3% — gobreaker 三件套落地

下单服务强依赖库存 RPC, 库存一抖单量就归零。给库存调用包上熔断器: 错误率超阈值拉闸, 冷却后半开探测, 熔断期读本地缓存兜底。

// github.com/sony/gobreaker v1 (Go 1.21)
var invCB = gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:        "inventory-rpc",
    MaxRequests: 3,                 // 半开放行的探测请求数
    Interval:    10 * time.Second,  // Closed 态错误率统计窗口
    Timeout:     30 * time.Second,  // Open → Half-Open 冷却时长
    ReadyToTrip: func(c gobreaker.Counts) bool { // 近 20 次错一半拉闸
        return c.Requests >= 20 && float64(c.TotalFailures)/
            float64(c.Requests) >= 0.5
    },
})
func stock(ctx context.Context, sku string) (int, error) {
    v, err := invCB.Execute(func() (interface{}, error) {
        return invClient.Get(ctx, sku)     // 受熔断保护的调用
    })
    if err != nil {                        // ErrOpenState: 熔断期快速失败
        return localCache.Get(sku), nil    // 关键: 读缓存旧值兜底, 不报错给用户
    }
    return v.(int), nil
}

库存彻底挂掉的 40 分钟里, 下单成功率只跌 3% — 熔断期全部走缓存旧值。

场景 2 · Java 团队不手写状态机 — Resilience4j 注解版熔断 + 舱壁 + 超时

Spring Boot 项目用 resilience4j 注解三行挂上三道保护; 熔断、舱壁、超时按实例名独立配置, 粒度到方法级。

// resilience4j-spring-boot 2.x (Spring Boot 2.7)
@CircuitBreaker(name = "inventory", fallbackMethod = "stockFallback")
@Bulkhead(name = "inventory", type = Bulkhead.Type.SEMAPHORE)
@TimeLimiter(name = "inventory")               // 需返回 CompletableFuture
public CompletableFuture<Stock> getStock(String sku) {
    return CompletableFuture.supplyAsync(() -> client.query(sku));
}
public CompletableFuture<Stock> stockFallback(String sku, CallNotPermittedException e) {
    return CompletableFuture.completedFuture(cache.get(sku)); // 熔断期兜底
}
// 关键: fallback 签名 = 原参数 + 末尾异常; Open 时抛 CallNotPermittedException

场景 3 · 503 风暴被重试放大三倍之后 — 指数退避 + Full Jitter 统一重试模板

计费服务偶发 503, 各处手写 for 循环重试, 风暴来时 QPS ×3。抽一个统一模板: 退避、抖动、错误白名单、上游预算四件事一次做对。

func Retry(ctx context.Context, op func() error) error {
    base := 100 * time.Millisecond
    for attempt := 0; attempt < 5; attempt++ {
        if err := op(); err == nil {
            return nil
        } else if !retryable(err) { // 400/401/404 不在白名单
            return err
        }
        d := base << attempt // 100ms → 200ms → 400ms → 800ms
        if d > 5*time.Second { d = 5 * time.Second } // cap 封顶
        d = time.Duration(rand.Int63n(int64(d))) // Full Jitter: [0,d) 均匀取
        select {
        case <-time.After(d):            // 关键: 抖动打散重试齐射
        case <-ctx.Done():
            return ctx.Err()             // 上游预算没了, 立刻收手
        }
    }
    return errors.New("retry budget exhausted")
}

全公司收敛到这一个模板后, 503 风暴期的下游放大系数从 3 降到 1.2。

场景 4 · 推荐接口熔断后白屏三天 — 降级读缓存兜底, 挂了 GMV 只掉 1%

推荐服务依赖 3 个下游, 任一抖动就熔断; 之前熔断直接抛错导致前端白屏。现在熔断期读"上次成功的榜单缓存", 并打降级标记。

func recommend(ctx context.Context, uid int64) []Ad {
    ads, err := recCB.Execute(func() (interface{}, error) {
        return recClient.ForUser(ctx, uid)
    })
    if err != nil {                        // 熔断打开或 RPC 失败
        if cached, ok := boardCache.Get(uid); ok {
            return markDegraded(cached)    // 关键: 旧榜单 + degraded 标记
        }
        return hotDefaultBoard             // 再兜一层: 全站热门榜
    }
    boardCache.Set(uid, ads, 24*time.Hour) // 成功结果顺手写兜底缓存
    return ads
}

场景 5 · 报表任务一夜拖挂支付回调 — 核心/非核心拆舱壁

两类业务共用 200 线程池, 报表导出占满后支付回调全部排队。拆成两个池: 核心独占配额, 非核心有界降速。

// 迁移前: static ExecutorService POOL = Executors.newFixedThreadPool(200);
// 报表一慢 → 200 线程全卡在导出 → 支付回调无线程可用, 全站支付超时
ExecutorService payPool = new ThreadPoolExecutor(
        32, 32, 0, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(100),               // 有界队列
        new ThreadPoolExecutor.AbortPolicy());       // 打满立即拒绝
ExecutorService reportPool = new ThreadPoolExecutor(
        8, 8, 0, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(200),
        new ThreadPoolExecutor.CallerRunsPolicy());  // 非核心: 降速不丢
// 关键: 核心池独占配额 + 有界队列, 非核心流量挤不进核心

场景 6 · 每月混沌演练日: Chaos Mesh 杀 30% 支付 pod + 对库存断网 3 分钟

降级代码写了没人信, 就用 Chaos Mesh 真刀真枪注入两类故障: 杀 pod 验证自愈, 断网验证熔断与降级, 演练报告直接当预案验收单。

apiVersion: chaos-mesh.org/v1alpha1
kind: PodChaos
metadata: { name: kill-pay-30 }
spec:
  action: pod-kill
  mode: fixed-percent
  value: "30"
  selector: { labelSelectors: { app: payment } }
  duration: 5m
---
# 演练二: 对库存服务双向断网, 验证熔断 30s 内打开并走兜底
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
spec:
  action: partition
  direction: both
  selector: { labelSelectors: { app: order } }
  target: { selector: { labelSelectors: { app: inventory } } }
  duration: 3m
# 关键: 演练验收指标 — 自愈 < 60s, 熔断打开率 100%

场景 7 · 凌晨 3 点 HikariPool 告警: 一个没配超时的下游拖死整个连接池

订单服务 DB 连接全部占用、pending 800+。排查发现不是 DB 慢, 是营销服务卡死而 HTTP 调用没设超时, Tomcat 200 线程全挂起, 每个线程还各押着一个连接。

$ kubectl logs order-svc-7d9f --since=10m | grep -c TimeoutException
12583
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not
  available, request timed out after 30000ms.
  at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:196)
$ curl -o /dev/null -sw '%{time_total}\n' http://marketing-svc/health
30.012     # ← 营销服务不拒绝也不响应, 一挂就是 30s
# 根因: Feign 未配超时, Tomcat 200 线程全挂起, 每线程还各押一个连接
# 修复: 给下游客户端补超时并独立舱壁隔离
feign.client.config.marketing.connectTimeout=200
feign.client.config.marketing.readTimeout=800
# 关键: 没有超时的调用, 等于拿线程和连接给下游当人质

场景 8 · 三副本同宿主机被一锅端之后 — 拓扑打散 + 中断预算做实冗余

4 个副本曾被调度器排在一台宿主机上, 宿主机重启全站下单不可用。用拓扑分布约束强制跨区, 再配 PDB 保证检修时容量。

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 4                         # 3 干活 + 1 冗余
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule  # 关键: 强制跨可用区
          labelSelector: { matchLabels: { app: order } }
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway   # 尽量打散宿主机
          labelSelector: { matchLabels: { app: order } }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
spec:
  minAvailable: 3   # 关键: 驱逐/检修时永远保 3 副本在线
  selector: { matchLabels: { app: order } }

场景 9 · 下单接口 2s 总预算的分配表 — 压测校准, 每层递减留余量

网关给下单 2s 总预算, 按依赖树分家: 每层自留一份, 子预算之和必须小于父预算; 数值来自全链路压测, 写进代码并随容量复审。

// 压测校准后的预算表 (P99: 库存 180ms / 营销 90ms / 券 120ms)
// 网关 2.0s ─┬─ 订单落库 300ms
//           ├─ 库存 RPC 500ms   (P99×2 + 抖动余量)
//           ├─ 营销 RPC 300ms
//           └─ 券核销  400ms    (与库存并行, 取 max 不叠加)
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) // 库存预算
defer cancel()
stock, err := inventoryClient.Deduct(ctx, req)
if errors.Is(err, context.DeadlineExceeded) {
    metrics.TimeoutCounter.WithLabelValues("inventory").Inc()
    return preDeduct(req) // 关键: 预扣 + 异步补偿, 不拖死入口预算
}

场景 10 · 熔断器在开/半开间反复横跳 — 恢复探测的样本数与口径设计

半开只放 1 个请求, 探测接口 P99 抖一下就重新熔断, 兜底缓存越来越旧。改成 3 探测 + 连败计数拉闸 + 自定义"成功"口径, 一天只横跳 0 次。

gobreaker.Settings{
    Name:        "pay-gateway",
    MaxRequests: 3,                // 关键: 3 个探测, 单个偶发慢不翻盘
    Interval:    10 * time.Second,
    Timeout:     60 * time.Second, // 冷却给足, 防在开/半开间反复横跳
    ReadyToTrip: func(c gobreaker.Counts) bool {
        return c.ConsecutiveFailures >= 5   // 连败才拉闸, 忽略零星抖动
    },
    IsSuccessful: func(err error) bool {    // 定制"成功"口径
        return err == nil || errors.Is(err, ErrBizRejected)
    },  // 业务拒绝(库存不足)不是故障, 不计入熔断统计
}

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 一秒内三次重试全打过去, 503 风暴更猛 — 症状: 故障期重试流量压垮下游, 越重越挂. 原因: 重试间隔为 0, 立刻重试等于自己给自己 DDoS. 正解: 指数退避 + 抖动, 给下游喘息窗口。
# 错: for i := 0; i < 3; i++ { call() }
#     → 下游 503 时 QPS 瞬间 ×4, 越重越挂
# 对: time.Sleep(jitter(base << attempt))  // 退避 + 抖动
坑 2 · 超时重试一次, 用户被扣了两次款 — 症状: 重试后资金重复变动. 原因: POST 扣款首次已成功但响应丢失, 重试变成第二笔. 正解: 幂等键 (订单号) + 服务端按键返回首次结果。
// 错: post("/pay", body)   // 首次已成功, 超时重试 → 扣款 2 次
// 对: idemKey = orderNo    // 服务端按幂等键返回首次结果
//     post("/pay", body, "Idempotency-Key: " + idemKey)
坑 3 · 平时 100 QPS 的接口, 故障时下游收到 2700 QPS — 症状: 重试把流量放大 27 倍. 原因: 网关×3 × 服务×3 × RPC×3, 三层各重试. 正解: 只在最内层重试, 上层只设超时; 再配全局重试预算 ≤10%。
# 错: 网关重试3 × 服务重试3 × RPC重试3 → 100 QPS 变 2700
# 对: 只在 RPC 层重试 3 次, 网关/服务层只设超时不重试
#     放大系数 27 → 3, 再配重试预算 ≤10% 兜底
坑 4 · 入口 2s 就报错, 请求却还在下游跑了 10s — 症状: 用户已超时, 线程仍被占. 原因: 各层独立 3s 超时不递减, 上游放弃后下游不知道. 正解: 预算递减 + deadline 透传 (gRPC/context)。
# 错: 每层 ctx timeout 3s → 最坏 4 层叠 12s, 入口早放弃
# 对: ctx, _ := context.WithTimeout(ctx, 500*time.Millisecond)
#     // 关键: 子预算 ⊂ 父剩余预算, deadline 随请求透传
坑 5 · 下游恢复 1 小时了, 熔断器还开着, 只能重启应用 — 症状: 熔断不会自动恢复. 原因: Open 态没配冷却转半开 (Timeout=0 或无状态机). 正解: 配冷却时间 + 半开放行探测, 让电闸自己合上。
// 错: Timeout: 0             // Open 态永不自动恢复, 只能重启
// 对: Timeout: 30 * time.Second  // 冷却后自动进 Half-Open
//     MaxRequests: 3              // 放 3 个探测, 过了才闭合
坑 6 · 一个报表任务把支付拖挂了 — 症状: 非核心业务弄死核心业务. 原因: 全部下游共用一个线程池, 慢下游烧光线程. 正解: 按下游/核心级拆独立池 (信号量或线程池)。
// 错: static ExecutorService POOL = Executors.newCachedThreadPool();
//     // → 无界线程 + 共享, 报表 800 线程把支付饿死
// 对: payPool / reportPool 各自独立 + 有界队列
坑 7 · 熔断触发时线上第一次跑降级代码, 直接 NPE — 症状: 灾难时刻兜底先崩. 原因: fallback 依赖的缓存和主链路是同一个 Redis, 且代码从未被执行过. 正解: 兜底走本地缓存/静态默认值 + 演练日强制触发。
// 错: Stock stockFallback(...) { return redis.get(sku); }
//     // Redis 和库存一起挂 → 兜底也挂, NPE 给用户
// 对: 兜底走本地缓存 + 静态默认榜, 演练日强制验证
坑 8 · 对方让你"等 2 秒", 你却连发三枪 — 症状: 429 之后重试全被拒. 原因: 限流响应带着 Retry-After, 客户端视而不见立即重试, 加剧限流. 正解: 读取 Retry-After, 按服务端建议的时间退避。
// 错: if resp.StatusCode == 429 { retry(now) }   // → 429 洪峰更高
// 对: wait := parseRetryAfter(resp)  // 服务端说等几秒就等几秒
//     time.Sleep(wait)
坑 9 · 熔断期接口返回 200, "服务开小差中"被下游缓存了三天 — 症状: 用户看到占位文案且刷新不掉. 原因: fallback 返回了错误占位数据且没打标记, 上游当正常数据缓存. 正解: 兜底必须返回可用旧值 并带 degraded 标记, 不写正式缓存。
// 错: return "service unavailable", nil   // 200 + 占位文本被当真
// 对: return cachedBoard, nil             // 旧榜单可用
//     resp.Header.Set("X-Degraded", "1")  // 关键: 打标, 不落正式缓存
坑 10 · 每天定时任务高峰, 熔断器准时打开, 下游明明是好的 — 症状: 熔断误报. 原因: 自身进程 Full GC 20s, 等待超时全被记成下游失败, 错误率虚高. 正解: 区分错误来源, 用 Resilience4j 慢调用率独立统计, 只把真失败 (连接拒绝/5xx) 计入阈值。
# 错: 把"本进程等待超时"全部算作下游错误 → GC 20s 触发熔断
# 对: slow-call-duration-threshold: 1s   # 慢调用单独统计
#     failure-rate-threshold: 50         # 只统计真失败(连接拒绝/5xx)
坑 11 · 抄来的 timeout: 3s, 大促当天批量超时 — 症状: 别人能跑你超时. 原因: 超时值抄自他人系统, 你的接口 P99 是 1.8s, 3s 全在悬崖边. 正解: 本系统压测定值 (P99×2~3), 并随容量复审逐层递减。
# 错: readTimeout=3000  # 抄来的; 实测 P99=1800ms, 高峰批量超时
# 对: 压测定值: 接口 P99=800ms → 该接口 2000ms
#     下游递减: svc 1500ms → rpc 800ms, 每层留余量
坑 12 · 重试高峰期 DB 写入量 ×5, 主从延迟飙到分钟级 — 症状: 重试把写放大打到数据库. 原因: 对 INSERT/UPDATE 类写盲重试, 失败的写其实已落库. 正解: 写操作必须幂等 (唯一键/ON DUPLICATE KEY), 或重试只放在可去重的消费侧。
-- 错: INSERT INTO orders(...) 超时后重试 → 同一订单写 5 次
-- 对: INSERT ... ON DUPLICATE KEY UPDATE id = id  -- 幂等写
--     或队列消费侧重试 + 去重表, 天然挡重
坑 13 · 熔断器打开了两小时, 群里没人发现 — 症状: 兜底默默吃旧数据无人知晓. 原因: 状态机藏在进程里, 只打 stdout 日志, 没有 gauge 和告警. 正解: OnStateChange 打点上报 + 打开超 1 分钟电话告警。
// 错: fmt.Println("cb state changed")  // stdout 日志无人看
// 对: cbStateGauge.WithLabelValues(name).Set(stateCode(to))
//     // → Prometheus 抓取, Open 超 1 分钟触发电话告警
坑 14 · 半开探测一次就闭合, 两秒后又打开, 反复横跳 — 症状: 熔断器在两态间抖动, 兜底缓存越来越旧. 原因: MaxRequests=1, 单个探测撞上偶发慢即判失败. 正解: 半开放 3~5 个样本, 多数通过才闭合, 冷却时间拉长。
// 错: MaxRequests: 1    // 一个偶发慢探测 → 立刻重新熔断
// 对: MaxRequests: 3    // 3 个探测样本
//     Timeout: 60 * time.Second  // 冷却拉长, 防横跳
坑 15 · 舱壁没超并发限制却 OOM 了 — 症状: 限流生效但内存爆炸. 原因: maxConcurrentCalls 限了执行并发, 排队队列 LinkedBlockingQueue 无上限, 请求在队列里堆积撑爆堆内存. 正解: 有界队列 + 超时排队, 拿不到名额快速拒绝。
// 错: new ThreadPoolExecutor(50, 50, 0, MS, new LinkedBlockingQueue<>());
//     // → 无界队列: 积压 30w 任务, 堆内存 4GB 撑爆
// 对: new ArrayBlockingQueue<>(100) + AbortPolicy 快速拒绝
坑 16 · 事故日第一次按降级开关, 流量全打到没数据的灰度库 — 症状: 预案第一次实战就出事. 原因: chaos 只在测试环境演过, 生产流量模型差两个量级, 预案从未在真实容量下验证. 正解: 生产金丝雀注入, 爆炸半径控制在单 pod/单可用区。
# 错: 只在测试环境演练 → 生产第一次真刀真枪
# 对: mode: one               # 生产金丝雀: 只注入 1 个 pod
#     selector: { labelSelectors: { app: pay, track: canary } }
坑 17 · 自愈脚本把正在主从切换的库主杀了, 脑裂 2 小时 — 症状: 自愈制造更大的事故. 原因: 脚本只看心跳超时, 不知道 DBA 正在做计划内切换的宽限期. 正解: 自愈动作前检查维护锁/leader 租约, 加人工确认闸门。
# 错: if ! ping -W 2 db-master; then kill -9 $(pgrep mysqld); fi
#     # → 计划内切换的宽限期里被误杀, 双主脑裂
# 对: 自愈前读维护锁: etcdctl get maintenance/lock 存在则跳过
坑 18 · 三个副本同时失联, 服务可用性归零 — 症状: "高可用"部署集体消失. 原因: 没配反亲和, 调度器把 pod 全排在同一宿主机/机柜, 断电一锅端. 正解: topologySpreadConstraints 强制跨 zone/hostname。
# 错: replicas: 3 默认调度 → 3 副本同宿主机, 断电全灭
# 对: topologySpreadConstraints:
#       topologyKey: topology.kubernetes.io/zone   # 强制跨区
坑 19 · 重试风暴三天, 日志盘写满, 真正的错误被淹掉 — 症状: 排障时磁盘满、日志系统瘫. 原因: 每次重试都打 ERROR 全栈, 100 倍日志量打爆采集与磁盘. 正解: 重试日志改指标计数, 采样 + 首次告警后静默。
// 错: log.Errorf("retry %d failed: %v", i, err)  // ×100 流量 → 磁盘满
// 对: retryCounter.Inc()                          // 指标计数代替日志
//     if retryCounter.Rate() > 0.1 { alertOnce() } // 关键: 超阈值只告一次
坑 20 · 重试加到 10 次还是失败, 而且更糟了 — 症状: 加大重试毫无改善甚至恶化. 原因: 参数错误、业务拒绝是永久性失败, 重试只会白打还占预算; 只有瞬时故障值得重试. 正解: 先分类错误, 永久失败直接进死信/人工。
// 错: retry(err, 10)   // 401 参数错误重试 10 次全 401, 白打 10 枪
// 对: if isTransient(err) { retry(err, 3) } else { toDLQ(err) }
//     // 关键: 只重试瞬时故障, 永久失败直接兜底