Skip to content

生产事故复盘:微服务雪崩场景与治理,如何从被动告警到主动防御

问题:一个服务慢了,为什么整个系统都挂了?

微服务架构有个经典噩梦:某个服务响应变慢(比如数据库慢 SQL 从 10ms 变成 10s),结果整条调用链上的服务全部挂掉。 这不是个例,几乎每个上规模的微服务团队都踩过这坑。

传导链条很清晰:

慢服务 → 上游调用方线程耗尽 → 上游服务不可用 → 更上游的调用方线程耗尽 → 全线崩溃

拆开来看一个真实案例(某电商平台 2023 年双 11 压测事故):

  • 订单服务 调用 库存服务,库存服务因数据库慢查询(SELECT * FROM inventory WHERE sku_id IN (?,?,?,...) 未命中索引,全表扫描 3000 万行)响应从 10ms 变成 10s。
  • 订单服务的 Tomcat 线程池(默认 200 个线程)在 10 秒内全部被堵住:200 × 10s = 2000 个请求排队,新请求直接拒绝(Connection refused)。
  • 订单服务挂掉后,网关层(Nginx + Kong)调用订单服务的线程也被堵住——网关 upstream 连接池 500 个连接耗尽,网关返回 502。
  • 最终 全站瘫痪,所有经过网关的流量全部失败,持续 23 分钟才恢复。

关键数据:该事故中,库存服务 QPS 从 800 突降到 3(因为几乎全部超时),但订单服务线程池 200 个线程被全部占用,导致订单服务本身 QPS 从 2000 降到 0。雪崩的放大效应是 线性的线程耗尽,不是按比例稀释。

分析:雪崩的四层防御,缺一层都不行

雪崩治理不是靠一个组件就能搞定的,需要四层防御层层设防。缺少任何一层,其他层的工作量都会翻倍。

第一层:超时控制——最基本的防线

每个 RPC 调用必须设置超时,而且分两层:

  • 连接超时(connectTimeout):500ms,连不上就快速失败,别等着。
  • 读取超时(readTimeout):2s,服务端处理太久就放弃,释放线程。
java
// 以 Spring Cloud OpenFeign 为例,配置超时
feign:
  client:
    config:
      default:
        connectTimeout: 500
        readTimeout: 2000

// 自定义 FeignClient 的超时
@FeignClient(name = "inventory-service", configuration = InventoryFeignConfig.class)
public interface InventoryClient {
    @GetMapping("/api/stock/{skuId}")
    StockResponse getStock(@PathVariable("skuId") String skuId);
}

@Configuration
public class InventoryFeignConfig {
    @Bean
    public Request.Options requestOptions() {
        return new Request.Options(500, TimeUnit.MILLISECONDS, 2000, TimeUnit.MILLISECONDS, true);
    }
}

注意:超时不是越长越好。超时时间 × 线程池大小 = 最大排队时间。如果线程池 200 个线程,每个请求超时 30s,新请求最多等 6000 秒——等于没设超时。

踩坑 1:很多团队只配了 connectTimeout,没配 readTimeout。Spring Cloud 2020 之前的 Feign 默认 readTimeout 是 60s,等于没设。排查时发现接口 3s 就挂了,但配置的 readTimeout 是 60s,线程池早就满了。

踩坑 2:gRPC 的 Deadline 机制和 HTTP 超时不一样。gRPC 是客户端传递 deadline 到服务端,服务端可以在 deadline 到达前主动取消处理。而 HTTP 超时是客户端单方面断开,服务端可能还在继续处理。

java
// gRPC 客户端设置 deadline
stub.withDeadline(Deadline.after(2, TimeUnit.SECONDS))
    .getStock(StockRequest.newBuilder().setSkuId("123").build());

超时配置的"分层"策略:不要所有接口用一个超时值。按接口类型分类设置:

接口类型连接超时读取超时典型场景
读接口(缓存命中)200ms500ms商品详情、用户信息
读接口(数据库查询)500ms2s订单列表、搜索
写接口1s5s创建订单、支付
批量接口1s10s批量导入、报表导出
第三方 API2s10s短信、支付回调

踩坑 3:某次把批量接口的超时设成 30s,结果数据库连接池满的时候,所有线程都在等批量接口返回,30s × 200 线程 = 6000s 的线程占用时间。批量接口应该单独走异步线程池,不要和主线程池混在一起。

第二层:熔断——被动防御的核心

超时只能让单个请求不卡住线程,但大量请求同时涌入时,超时解决不了问题。熔断器才是关键。

熔断器状态机:关闭 → 打开 → 半开 → 关闭(或重新打开)

关闭状态 ──(失败率 > 阈值)──→ 打开状态 ──(休眠时间到)──→ 半开状态
  ↑                                                          │
  └──────────(请求成功)──────────┘  └──(请求失败)──→ 重新打开

熔断器三个核心参数(以 Resilience4j 为例):

参数默认值生产推荐值说明
failureRateThreshold50%50%滑动窗口内失败率超过此值则熔断
slidingWindowSize10020滑动窗口大小(请求数),小窗口更敏感
minimumNumberOfCalls10010触发熔断的最小请求数,防止刚启动即熔断
waitDurationInOpenState60s5s熔断打开后等待多久进入半开
permittedNumberOfCallsInHalfOpenState103半开状态允许通过的请求数

为什么推荐 slidingWindowSize=20 而不是 100? 100 个请求的窗口意味着要积累 100 个请求才能触发熔断,在高并发下 100 个请求就是一瞬间的事,但窗口太小又容易误判。20 是个平衡点,3 秒内能积累足够的样本。

半开状态的大坑:默认的半开状态只放行 3 个请求,如果这 3 个请求恰好落在正常节点上,熔断器就会关闭,但实际问题的节点可能还在。生产建议:半开状态只放行 1% 流量,逐步放开到 10%、50%、100%,每个阶段持续 10-20 个心跳周期。

java
// Resilience4j 渐进式恢复配置
@Bean
public Customizer<Resilience4jCircuitBreakerFactory> circuitBreakerCustomizer() {
    return factory -> {
        factory.configureDefault(id -> {
            CircuitBreakerConfig config = CircuitBreakerConfig.custom()
                .slidingWindowSize(20)
                .minimumNumberOfCalls(10)
                .failureRateThreshold(50)
                .waitDurationInOpenState(Duration.ofSeconds(5))
                .permittedNumberOfCallsInHalfOpenState(3)
                .slidingWindowType(SlidingWindowType.COUNT_BASED)
                .build();
            
            // 自定义渐进式恢复:通过 EventConsumer 监听半开事件
            // 实际需要配合外部调度器实现渐进式放量
            return config;
        });
    };
}

P7 级追问:熔断器能保护所有场景吗?

不能。熔断器保护的是服务间调用,不是数据库连接。如果数据库连接池满了,所有服务都连不上数据库,熔断器根本碰不到数据库这一层。

数据库连接池满的治理方案:

方案说明缺点
设置连接池上限HikariCP 最大 50 个连接,根据 CPU 核数 × 2 + 1 估算上限太低会导致线程等待
慢 SQL 监控告警记录慢查询日志,超过 1s 告警慢 SQL 已经发生,只能事后补救
读写连接池分离读库和写库用不同的连接池增加运维复杂度
数据库中间件ShardingSphere 做连接池管理引入中间件后全链路延迟增加

Resilience4j vs Hystrix vs Sentinel 对比(面试高频):

维度HystrixResilience4jSentinel
维护状态已停维(2020)活跃活跃
线程隔离支持(线程池/信号量)信号量信号量
滑动窗口算法环形缓冲区(固定 10s 桶)环形缓冲区滑动窗口(精度更高)
动态规则配置不支持支持(通过配置中心)支持(控制台实时推送)
限流不支持通过 RateLimiter 模块内置
熔断降级支持支持支持
内存占用每个命令一个线程池低(无额外线程)

面试题:Hystrix 的线程池隔离和信号量隔离有什么区别?

线程池隔离:每个下游服务一个独立的线程池,调用方线程将请求放入队列后立即返回,下游线程池里的线程执行请求。好处是即使下游线程池耗尽,也不会影响调用方的 Tomcat 线程。代价是每个请求多一次线程切换(上下文切换约 3-5μs)。

信号量隔离:调用方线程直接调用下游,不创建额外线程。通过信号量限制并发数。好处是无额外线程切换开销,适合对延迟敏感的场景。缺点是如果信号量耗尽,调用方线程也会被阻塞。

生产选择:内部服务用信号量隔离(延迟敏感),外部依赖(如第三方 API)用线程池隔离(容错优先)。

Hystrix 线程池隔离的典型配置参数

yaml
# 以 Hystrix 配置为例(虽然已停维,但很多公司还在用)
hystrix:
  command:
    default:
      execution:
        isolation:
          strategy: THREAD
          thread:
            timeoutInMilliseconds: 2000
      circuitBreaker:
        enabled: true
        requestVolumeThreshold: 20
        sleepWindowInMilliseconds: 5000
        errorThresholdPercentage: 50
  threadpool:
    inventoryService:
      coreSize: 10
      maximumSize: 20
      maxQueueSize: 50
      queueSizeRejectionThreshold: 30
    orderService:
      coreSize: 20
      maximumSize: 40
      maxQueueSize: 100
      queueSizeRejectionThreshold: 50

:Hystrix 线程池的 maxQueueSize 设置后不能动态修改,且值必须在线程池初始化时确定。如果 queueSizeRejectionThreshold 设置比 maxQueueSize 小,队列满时会先拒绝而不是撑爆内存。

第三层:限流——主动防御

熔断是被动防御(下游挂了才触发),限流是主动防御(提前限制流量,防止下游被打挂)。

生产最佳实践是**"限流在前 + 熔断在后"**:

  • 网关层限流:挡住超出集群处理能力的流量。
  • 服务间限流:针对每个接口设置 QPS 阈值,保护自身。
java
// Sentinel 注解方式,接口限流
@SentinelResource(value = "getStock", blockHandler = "getStockBlockHandler")
public StockResponse getStock(String skuId) {
    return inventoryClient.getStock(skuId);
}

public StockResponse getStockBlockHandler(String skuId, BlockException e) {
    log.warn("库存接口被限流,请求被拦截。skuId={}", skuId);
    return new StockResponse(skuId, 0, StockStatus.LIMITED);
}

Sentinel 的滑动窗口算法是高频面试点:1 秒切成 2 个 500ms 的窗口,统计当前窗口的请求数和异常数,滑动时丢弃旧窗口加新窗口。相比 Hystrix 的环形缓冲区(固定 10 秒桶),Sentinel 的滑动窗口统计更精确、内存占用更低。

Sentinel 滑动窗口源码分析

java
// Sentinel 1.8.x 的 LeapArray 核心逻辑
public class LeapArray<T> {
    // 窗口长度(毫秒),如 500ms
    private int windowLengthMs;
    // 采样窗口数量,如 2 个
    private int sampleCount;
    // 间隔时间,如 1000ms
    private int intervalInMs;
    // 环形数组,存储每个窗口的数据
    private final AtomicReferenceArray<WindowWrap<T>> array;
    
    // 获取当前时间对应的窗口
    public WindowWrap<T> currentWindow(long timeMillis) {
        // 计算当前时间落在哪个窗口下标
        int idx = calculateTimeIdx(timeMillis);
        // 窗口的开始时间
        long windowStart = calculateWindowStart(timeMillis);
        
        while (true) {
            WindowWrap<T> old = array.get(idx);
            if (old == null) {
                // 窗口未初始化,创建新窗口
                WindowWrap<T> window = new WindowWrap<>(windowLengthMs, windowStart, newEmptyBucket(timeMillis));
                if (array.compareAndSet(idx, null, window)) {
                    return window;
                } else {
                    Thread.yield();
                }
            } else if (windowStart == old.windowStart()) {
                // 窗口命中,直接返回
                return old;
            } else if (windowStart > old.windowStart()) {
                // 窗口已过期,重置
                if (array.compareAndSet(idx, old, new WindowWrap<>(windowLengthMs, windowStart, newEmptyBucket(timeMillis)))) {
                    return array.get(idx);
                }
            } else {
                return old;
            }
        }
    }
}

三种限流算法的对比

算法原理优点缺点适用场景
固定窗口每秒计数,超限则拒绝实现简单临界问题(窗口切换时流量翻倍)简单限流
滑动窗口将时间窗口切分为多个小格子,滑动统计精度高,无临界问题内存占用随格子数增加Sentinel 默认
漏桶请求进桶,固定速率出水平滑流量,适合突发不能应对突发流量流量整形
令牌桶固定速率生成令牌,请求消耗令牌允许一定突发突发过大时仍可能打爆下游网关限流(Nginx limit_req)

Nginx 网关层限流配置(生产线上最常见的方案):

nginx
# nginx.conf 限流配置
http {
    # 定义限流区域:每个 IP 每秒 10 个请求,突发 20 个
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
    limit_req_zone $server_name zone=server_limit:10m rate=1000r/s;
    
    # 突发限制
    limit_req zone=api_limit burst=20 nodelay;
    limit_req zone=server_limit burst=500 nodelay;
    
    # 连接数限制
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
    limit_conn conn_limit 10;
    
    server {
        location /api/ {
            # 限流状态码默认 503,可以改成 429
            limit_req_status 429;
            limit_conn_status 429;
            proxy_pass http://backend;
        }
    }
}

Nginx 限流的坑rate=10r/s 在 Nginx 里是每 100ms 放一个请求,不是每秒在 1s 窗口放 10 个。如果客户端突发 100ms 内发 10 个请求,就算 burst=20,也会被限流只放 1 个。nodelay 参数让超出 burst 的请求直接返回 429,不排队。

限流阈值怎么定? 不是拍脑瓜定的。基于全链路压测:压测确定库存服务的最大处理能力(比如 500 QPS 时 TP99 稳定在 100ms,超过 600 QPS 时 TP99 飙升到 2s),那么限流阈值就设在 500 QPS。

限流参数动态调整:生产环境限流阈值不能写死在代码里。通过配置中心(Nacos/Apollo)动态下发,实时生效:

java
// Sentinel 动态规则配置(从 Nacos 拉取)
@PostConstruct
public void initFlowRules() {
    ReadableDataSource<String, List<FlowRule>> flowRuleDataSource = new NacosDataSource<>(
        "localhost:8848",
        "DEFAULT_GROUP",
        "sentinel-inventory-flow-rules",
        source -> JSON.parseObject(source, new TypeReference<List<FlowRule>>() {})
    );
    FlowRuleManager.register2Property(flowRuleRepository.getProperty());
}

第四层:全链路压测 + 容量规划

技术方案只能解决"已知问题",压测才能发现"未知问题"。

  • 每季度至少一次全链路压测,摸清系统容量上限。
  • 制定降级预案:哪些服务可以降级(如非核心的评价服务返回缓存数据),哪些服务必须保(如支付服务)。
  • 压测流量元数据隔离:压测数据打特殊标记(如 userId 取模落在压测区间),业务代码自动识别压测流量,走独立的压测数据库或影子表。
java
// 压测流量标记与隔离示例
@Component
public class PressureTestInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String userId = request.getHeader("X-User-Id");
        if (userId != null && isPressureTestUser(userId)) {
            // 设置压测标记到 ThreadLocal
            PressureTestContext.set(true);
            // 切换数据源到影子表
            DataSourceContextHolder.setDataSource("shadow");
        }
        return true;
    }
}

全链路压测的完整流程

压测准备阶段:
  ├── 确定压测目标(QPS 目标:5000、TP99 目标:200ms)
  ├── 准备压测数据(影子表创建、压测用户标识注册)
  ├── 压测脚本编写(JMeter/Gatling 脚本,模拟真实用户行为)
  └── 监控大盘确认(Prometheus + Grafana 所有关键指标可见)

压测执行阶段:
  ├── 梯度加压:100 → 500 → 1000 → 2000 → 5000 QPS
  ├── 每个梯度持续 5 分钟,观察 TP99 和错误率
  ├── 发现瓶颈时记录并标记,不中断压测
  └── 达到目标后继续压测 10 分钟观察稳定性

压测复盘阶段:
  ├── 输出压测报告(瓶颈点、最大容量、建议改进项)
  ├── 根据瓶颈制定改进计划(SQL 优化、缓存增加、服务扩容)
  └── 改进后重新压测,验证改进效果

压测常见的坑

  1. 压测数据污染线上数据:没有做影子表隔离,压测流量写的订单数据混入正式订单,导致对账不平。
  2. 压测只测单链路:只压了订单服务,没压全链路,遗漏了网关层和缓存层的瓶颈。
  3. 压测后不恢复阈值:压测调大了限流阈值,压测结束后忘记改回来,下次线上流量突增时直接打垮。
  4. 压测脚本脱节:压测脚本只发 GET 请求,但实际用户行为是混合读写,导致压测结果偏高。
  5. 忽略预热:JVM 的 JIT 编译需要时间,缓存也需要预热。刚启动就压测,结果比实际偏低 30%-50%。

进阶:从被动告警到主动防御

被动告警是"挂了才报警"——订单服务线程池满了,5xx 错误率飙升,然后才有人去看。

主动防御是"提前设防":

  • Sentinel 的限流规则、熔断阈值、线程池隔离。
  • CPU 使用率超过 70% 自动扩容(HPA)。
  • 熔断器半开状态不仅仅放一个请求,而是渐进式恢复:每次半开放 1% 的流量,无异常后逐步放开到 10% → 50% → 100%。

渐进式恢复的时序图

时间线:
0s    熔断器打开,所有请求走 fallback
5s    进入半开,放行 1% 流量(20 个请求中放 1 个)
10s   全部正常,放大到 10%
15s   全部正常,放大到 50%
20s   全部正常,关闭熔断器,100% 放行
(如果某一步异常,回到 0s 重新开始)

Kubernetes HPA 自动扩容配置(基于自定义指标实现主动防御):

yaml
# HPA 配置:基于自定义指标自动扩容
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: inventory-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: inventory-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
  - type: Pods
    pods:
      metric:
        name: tomcat_threads_busy_percent
      target:
        type: AverageValue
        averageValue: 70
  - type: Pods
    pods:
      metric:
        name: http_server_requests_seconds_p99
      target:
        type: AverageValue
        averageValue: 0.5  # 500ms
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
      - type: Percent
        value: 100
        periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 50
        periodSeconds: 60

P7 级必须具备的容量思维:每个服务上线前必须回答三个问题:

  1. 这个服务的 QPS 峰值是多少?
  2. 如果下游全部挂掉,这个服务能撑多久?
  3. 降级预案是什么?

事故复盘模板

不要只写"技术原因"。写清楚:

  1. 什么时间点、什么服务先变慢?
  2. 为什么没有在 5 分钟内发现?
  3. 熔断/限流为什么没生效?
  4. 如何避免下次发生?

一个真实的事故复盘案例

时间事件响应
14:02:30库存服务数据库慢查询触发,响应从 10ms 升到 8s无告警(慢查询阈值设了 10s)
14:03:15订单服务线程池占用率 100%无告警(线程池监控没接入)
14:04:00网关 502 错误率飙升 60%告警触发
14:05:30值班人员收到告警,开始排查
14:26:00回滚库存服务数据库索引变更,恢复全程 23.5 分钟

根因:库存服务 DBA 在线添加索引时,MySQL 8.0 的 Online DDL 虽然支持并发 DML,但 ADD INDEX 操作在 InnoDB 层面需要等待所有未提交事务释放,而一个长事务堵住了,DDL 操作阻塞了后续所有 SQL。

改进措施

  1. 慢查询 SQL 阈值从 10s 改为 1s
  2. 线程池监控接入 Prometheus(tomcat.threads.busy 指标)
  3. 数据库 DDL 操作必须先在从库执行,观察无异常后才在主库执行
  4. 网关层增加熔断触发(Kong 的 upstream 健康检查)

另一个事故复盘:缓存雪崩导致的连锁故障

某社交平台 2024 年春节活动,Redis 主节点宕机后哨兵选举耗时 12 秒,期间所有缓存查询直穿数据库。数据库连接池默认 50 个连接,被 12 秒内涌入的 10 万 QPS 打满。数据库 CPU 从 20% 飙到 95%,查询响应从 5ms 升到 15s。最终导致整个 feed 流服务不可用 18 分钟。

根因分析

  1. Redis 主从切换期间缓存穿透无保护
  2. 本地缓存(Caffeine)没开启,所有请求都走远程缓存
  3. 数据库连接池无限流,10 万 QPS 全部打向数据库
  4. 数据库连接池无读写分离,读请求和写请求共享连接

改进措施

  1. 所有服务增加本地缓存层(Caffeine),缓存时间 30s,即使 Redis 挂了也能撑 30s
  2. 数据库连接池增加队列和拒绝策略:超过 100 个并发请求直接返回降级数据
  3. 数据库连接池分读写:读库连接池 100 个,写库连接池 20 个
  4. Redis 主从切换期间,网关层主动降级非核心功能
java
// 多层缓存兜底策略
@Service
public class FeedService {
    // 本地缓存(一级缓存)
    private final Cache<String, Feed> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(30, TimeUnit.SECONDS)
        .build();
    
    // Redis 缓存(二级缓存)
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    public Feed getFeed(String userId) {
        // 1. 查本地缓存
        Feed feed = localCache.getIfPresent(userId);
        if (feed != null) return feed;
        
        // 2. 查 Redis
        String json = redisTemplate.opsForValue().get("feed:" + userId);
        if (json != null) {
            feed = JSON.parseObject(json, Feed.class);
            localCache.put(userId, feed);
            return feed;
        }
        
        // 3. 查数据库(带本地限流)
        if (DatabaseCircuitBreaker.tryAcquire()) {
            try {
                feed = feedMapper.getFeed(userId);
                if (feed != null) {
                    redisTemplate.opsForValue().set("feed:" + userId, JSON.toJSONString(feed), 1, TimeUnit.HOURS);
                    localCache.put(userId, feed);
                }
            } finally {
                DatabaseCircuitBreaker.release();
            }
        } else {
            // 数据库不可用,返回空数据或默认数据
            return Feed.empty();
        }
        
        return feed;
    }
}

总结

微服务雪崩不是"运气不好",是防御体系有缺口。四层防御层层递进:

层次手段作用缺了会怎样
第一层超时控制单个请求不卡住线程一个慢请求卡死所有线程
第二层熔断下游挂了,上游不拖死慢服务扩散到整条链路
第三层限流提前挡住超量流量流量尖峰打垮所有服务
第四层全链路压测发现未知问题,设定容量水位上线后才发现扛不住

一句话:超时兜底,熔断断开,限流设防,压测验证。少一层,出事概率翻倍。

唯一可靠的策略是让每一层都能独立工作——不要指望网关层限流了就万事大吉,服务间调用也要配熔断。做过全链路压测的团队,处理线上事故的底气完全不一样。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。