挂了怎么继续跑: 超时止损 → 退避重试 → 熔断快速失败 → 舱壁隔离 → 降级兜底, 把"单点慢全站塌"改写成"局部失败整体存活"
把微服务想成一栋楼: 超时是"最多等你 2 秒"的门规, 重试是敲门再试但会吵醒全楼, 熔断是跳闸的电闸, 舱壁是船底的防水隔间, 降级是火灾时关掉广告牌保住消防通道。单机时代一个 try/catch 就够, 分布式时代失败是常态, 真正的问题是"失败之后系统怎么继续跑"。
机制本质: 用快速失败代替无限等待, 用指数退避防止重试踩踏, 用错误率统计自动拉闸, 用资源隔离把爆炸半径锁进一个舱室, 用预案兜底把"报错"翻译成"次好的答案"。五招缺一, 级联故障 (Cascading Failure) 就能把一个下游 500ms 的抖动放大成全站 503。
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)ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond) defer cancel() out, err := inventoryClient.Query(ctx, req) // gRPC: 剩余 deadline 透传 // 关键: 下游最多用 1.5s, 不是自己配置里的 3s // → 超时后上游立即收到 DeadlineExceeded, 不再傻等
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)
d := base << attempt sleep := time.Duration(rand.Int63n(int64(d))) // Full Jitter: [0,d) 均匀 // 关键: 无抖动时 1w 个客户端会在同一毫秒一起重试 // → 重试齐射被摊成均匀噪声, 下游恢复更快
allow := retried*10 <= total // 已重试占比 ≤ 10% 才放行 // 关键: 把重试量钉在总量一成, 故障不被重试放大 // → total=1000, retried=99 → true; retried=101 → false
_, err := cb.Execute(func() (interface{}, error) { return callInv(ctx) }) if errors.Is(err, gobreaker.ErrOpenState) { return fallback(ctx) // 关键: Open 态不碰下游, 直接走兜底 } // → 错误率过阈值后, Execute 立即返回 ErrOpenState
gobreaker.Settings{
MaxRequests: 3, // 半开同时放 3 个探测请求
Timeout: 30 * time.Second, // 关键: Open → Half-Open 冷却时长
Interval: 10 * time.Second, // Closed 态错误率统计窗口
}
// → 冷却 30s 后放 3 个探测, 全过回 Closed, 有失败回 Openresilience4j.bulkhead.instances.pay-core: max-concurrent-calls: 50 # 支付核心独占 50 并发 resilience4j.bulkhead.instances.report: max-concurrent-calls: 10 # 报表 10, 挤爆只烧自己 # 关键: 池不共享, 报表挂死支付照常 # → 报表打满只拒绝报表请求, 支付池不受影响
@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 } // → 熔断期用户看到旧推荐, 而不是白屏
degrade: recommend: cache # 推荐降级读缓存旧值 comment: off # 评论: 直接下线不展示 pay: full # 支付全量 — 核心链路不动 # 关键: 开关提前配好并演练, 故障时一键切换
// B 卡死 → A 的 200 个 Tomcat 线程全停在 socketRead0 // → A 无法响应, A 的上游跟着超时, 传染两层 java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms. // 关键: 没有超时/熔断, 故障必然沿链反向传染
replicas: 4 # 3 副本干活 + 1 份冗余 topologySpreadConstraints: - topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule # 关键: 打散宿主机, 同机的多副本是假冗余 # → 任一宿主机故障, 剩余节点仍扛得住
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
failureThreshold: 3 # 连续 3 次失败才动手
# 关键: 重启要快且幂等, 别把正在恢复的实例杀掉
# → 容器假死 30s 内被探测发现并重启拉起apiVersion: chaos-mesh.org/v1alpha1 kind: PodChaos # Chaos Mesh: 杀 pod 验证自愈 spec: action: pod-kill mode: fixed-percent value: "30" # 关键: 杀 30%, 验证剩余容量兜不兜得住 # → 演练报告: 自愈耗时 47s, 熔断 31s 内打开
下单服务强依赖库存 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% — 熔断期全部走缓存旧值。
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
计费服务偶发 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。
推荐服务依赖 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 }
两类业务共用 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()); // 非核心: 降速不丢 // 关键: 核心池独占配额 + 有界队列, 非核心流量挤不进核心
降级代码写了没人信, 就用 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%
订单服务 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 # 关键: 没有超时的调用, 等于拿线程和连接给下游当人质
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 } }
网关给下单 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) // 关键: 预扣 + 异步补偿, 不拖死入口预算 }
半开只放 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)
}, // 业务拒绝(库存不足)不是故障, 不计入熔断统计
}
# 错: for i := 0; i < 3; i++ { call() } # → 下游 503 时 QPS 瞬间 ×4, 越重越挂 # 对: time.Sleep(jitter(base << attempt)) // 退避 + 抖动
// 错: post("/pay", body) // 首次已成功, 超时重试 → 扣款 2 次 // 对: idemKey = orderNo // 服务端按幂等键返回首次结果 // post("/pay", body, "Idempotency-Key: " + idemKey)
# 错: 网关重试3 × 服务重试3 × RPC重试3 → 100 QPS 变 2700 # 对: 只在 RPC 层重试 3 次, 网关/服务层只设超时不重试 # 放大系数 27 → 3, 再配重试预算 ≤10% 兜底
# 错: 每层 ctx timeout 3s → 最坏 4 层叠 12s, 入口早放弃 # 对: ctx, _ := context.WithTimeout(ctx, 500*time.Millisecond) # // 关键: 子预算 ⊂ 父剩余预算, deadline 随请求透传
// 错: Timeout: 0 // Open 态永不自动恢复, 只能重启 // 对: Timeout: 30 * time.Second // 冷却后自动进 Half-Open // MaxRequests: 3 // 放 3 个探测, 过了才闭合
// 错: static ExecutorService POOL = Executors.newCachedThreadPool(); // // → 无界线程 + 共享, 报表 800 线程把支付饿死 // 对: payPool / reportPool 各自独立 + 有界队列
// 错: Stock stockFallback(...) { return redis.get(sku); } // // Redis 和库存一起挂 → 兜底也挂, NPE 给用户 // 对: 兜底走本地缓存 + 静态默认榜, 演练日强制验证
// 错: if resp.StatusCode == 429 { retry(now) } // → 429 洪峰更高 // 对: wait := parseRetryAfter(resp) // 服务端说等几秒就等几秒 // time.Sleep(wait)
// 错: return "service unavailable", nil // 200 + 占位文本被当真 // 对: return cachedBoard, nil // 旧榜单可用 // resp.Header.Set("X-Degraded", "1") // 关键: 打标, 不落正式缓存
# 错: 把"本进程等待超时"全部算作下游错误 → GC 20s 触发熔断 # 对: slow-call-duration-threshold: 1s # 慢调用单独统计 # failure-rate-threshold: 50 # 只统计真失败(连接拒绝/5xx)
# 错: readTimeout=3000 # 抄来的; 实测 P99=1800ms, 高峰批量超时 # 对: 压测定值: 接口 P99=800ms → 该接口 2000ms # 下游递减: svc 1500ms → rpc 800ms, 每层留余量
-- 错: INSERT INTO orders(...) 超时后重试 → 同一订单写 5 次 -- 对: INSERT ... ON DUPLICATE KEY UPDATE id = id -- 幂等写 -- 或队列消费侧重试 + 去重表, 天然挡重
// 错: fmt.Println("cb state changed") // stdout 日志无人看 // 对: cbStateGauge.WithLabelValues(name).Set(stateCode(to)) // // → Prometheus 抓取, Open 超 1 分钟触发电话告警
// 错: MaxRequests: 1 // 一个偶发慢探测 → 立刻重新熔断 // 对: MaxRequests: 3 // 3 个探测样本 // Timeout: 60 * time.Second // 冷却拉长, 防横跳
// 错: new ThreadPoolExecutor(50, 50, 0, MS, new LinkedBlockingQueue<>()); // // → 无界队列: 积压 30w 任务, 堆内存 4GB 撑爆 // 对: new ArrayBlockingQueue<>(100) + AbortPolicy 快速拒绝
# 错: 只在测试环境演练 → 生产第一次真刀真枪 # 对: mode: one # 生产金丝雀: 只注入 1 个 pod # selector: { labelSelectors: { app: pay, track: canary } }
# 错: if ! ping -W 2 db-master; then kill -9 $(pgrep mysqld); fi # # → 计划内切换的宽限期里被误杀, 双主脑裂 # 对: 自愈前读维护锁: etcdctl get maintenance/lock 存在则跳过
# 错: replicas: 3 默认调度 → 3 副本同宿主机, 断电全灭 # 对: topologySpreadConstraints: # topologyKey: topology.kubernetes.io/zone # 强制跨区
// 错: log.Errorf("retry %d failed: %v", i, err) // ×100 流量 → 磁盘满 // 对: retryCounter.Inc() // 指标计数代替日志 // if retryCounter.Rate() > 0.1 { alertOnce() } // 关键: 超阈值只告一次
// 错: retry(err, 10) // 401 参数错误重试 10 次全 401, 白打 10 枪 // 对: if isTransient(err) { retry(err, 3) } else { toDLQ(err) } // // 关键: 只重试瞬时故障, 永久失败直接兜底