生产事故复盘:微服务雪崩场景与治理,如何从被动告警到主动防御
问题:一个服务慢了,为什么整个系统都挂了?
微服务架构有个经典噩梦:某个服务响应变慢(比如数据库慢 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,服务端处理太久就放弃,释放线程。
// 以 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 超时是客户端单方面断开,服务端可能还在继续处理。
// gRPC 客户端设置 deadline
stub.withDeadline(Deadline.after(2, TimeUnit.SECONDS))
.getStock(StockRequest.newBuilder().setSkuId("123").build());超时配置的"分层"策略:不要所有接口用一个超时值。按接口类型分类设置:
| 接口类型 | 连接超时 | 读取超时 | 典型场景 |
|---|---|---|---|
| 读接口(缓存命中) | 200ms | 500ms | 商品详情、用户信息 |
| 读接口(数据库查询) | 500ms | 2s | 订单列表、搜索 |
| 写接口 | 1s | 5s | 创建订单、支付 |
| 批量接口 | 1s | 10s | 批量导入、报表导出 |
| 第三方 API | 2s | 10s | 短信、支付回调 |
踩坑 3:某次把批量接口的超时设成 30s,结果数据库连接池满的时候,所有线程都在等批量接口返回,30s × 200 线程 = 6000s 的线程占用时间。批量接口应该单独走异步线程池,不要和主线程池混在一起。
第二层:熔断——被动防御的核心
超时只能让单个请求不卡住线程,但大量请求同时涌入时,超时解决不了问题。熔断器才是关键。
熔断器状态机:关闭 → 打开 → 半开 → 关闭(或重新打开)
关闭状态 ──(失败率 > 阈值)──→ 打开状态 ──(休眠时间到)──→ 半开状态
↑ │
└──────────(请求成功)──────────┘ └──(请求失败)──→ 重新打开熔断器三个核心参数(以 Resilience4j 为例):
| 参数 | 默认值 | 生产推荐值 | 说明 |
|---|---|---|---|
| failureRateThreshold | 50% | 50% | 滑动窗口内失败率超过此值则熔断 |
| slidingWindowSize | 100 | 20 | 滑动窗口大小(请求数),小窗口更敏感 |
| minimumNumberOfCalls | 100 | 10 | 触发熔断的最小请求数,防止刚启动即熔断 |
| waitDurationInOpenState | 60s | 5s | 熔断打开后等待多久进入半开 |
| permittedNumberOfCallsInHalfOpenState | 10 | 3 | 半开状态允许通过的请求数 |
为什么推荐 slidingWindowSize=20 而不是 100? 100 个请求的窗口意味着要积累 100 个请求才能触发熔断,在高并发下 100 个请求就是一瞬间的事,但窗口太小又容易误判。20 是个平衡点,3 秒内能积累足够的样本。
半开状态的大坑:默认的半开状态只放行 3 个请求,如果这 3 个请求恰好落在正常节点上,熔断器就会关闭,但实际问题的节点可能还在。生产建议:半开状态只放行 1% 流量,逐步放开到 10%、50%、100%,每个阶段持续 10-20 个心跳周期。
// 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 对比(面试高频):
| 维度 | Hystrix | Resilience4j | Sentinel |
|---|---|---|---|
| 维护状态 | 已停维(2020) | 活跃 | 活跃 |
| 线程隔离 | 支持(线程池/信号量) | 信号量 | 信号量 |
| 滑动窗口算法 | 环形缓冲区(固定 10s 桶) | 环形缓冲区 | 滑动窗口(精度更高) |
| 动态规则配置 | 不支持 | 支持(通过配置中心) | 支持(控制台实时推送) |
| 限流 | 不支持 | 通过 RateLimiter 模块 | 内置 |
| 熔断降级 | 支持 | 支持 | 支持 |
| 内存占用 | 每个命令一个线程池 | 低(无额外线程) | 低 |
面试题:Hystrix 的线程池隔离和信号量隔离有什么区别?
线程池隔离:每个下游服务一个独立的线程池,调用方线程将请求放入队列后立即返回,下游线程池里的线程执行请求。好处是即使下游线程池耗尽,也不会影响调用方的 Tomcat 线程。代价是每个请求多一次线程切换(上下文切换约 3-5μs)。
信号量隔离:调用方线程直接调用下游,不创建额外线程。通过信号量限制并发数。好处是无额外线程切换开销,适合对延迟敏感的场景。缺点是如果信号量耗尽,调用方线程也会被阻塞。
生产选择:内部服务用信号量隔离(延迟敏感),外部依赖(如第三方 API)用线程池隔离(容错优先)。
Hystrix 线程池隔离的典型配置参数:
# 以 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 阈值,保护自身。
// 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 滑动窗口源码分析:
// 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.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)动态下发,实时生效:
// 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 取模落在压测区间),业务代码自动识别压测流量,走独立的压测数据库或影子表。
// 压测流量标记与隔离示例
@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 优化、缓存增加、服务扩容)
└── 改进后重新压测,验证改进效果压测常见的坑:
- 压测数据污染线上数据:没有做影子表隔离,压测流量写的订单数据混入正式订单,导致对账不平。
- 压测只测单链路:只压了订单服务,没压全链路,遗漏了网关层和缓存层的瓶颈。
- 压测后不恢复阈值:压测调大了限流阈值,压测结束后忘记改回来,下次线上流量突增时直接打垮。
- 压测脚本脱节:压测脚本只发 GET 请求,但实际用户行为是混合读写,导致压测结果偏高。
- 忽略预热: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 自动扩容配置(基于自定义指标实现主动防御):
# 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: 60P7 级必须具备的容量思维:每个服务上线前必须回答三个问题:
- 这个服务的 QPS 峰值是多少?
- 如果下游全部挂掉,这个服务能撑多久?
- 降级预案是什么?
事故复盘模板
不要只写"技术原因"。写清楚:
- 什么时间点、什么服务先变慢?
- 为什么没有在 5 分钟内发现?
- 熔断/限流为什么没生效?
- 如何避免下次发生?
一个真实的事故复盘案例:
| 时间 | 事件 | 响应 |
|---|---|---|
| 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。
改进措施:
- 慢查询 SQL 阈值从 10s 改为 1s
- 线程池监控接入 Prometheus(
tomcat.threads.busy指标) - 数据库 DDL 操作必须先在从库执行,观察无异常后才在主库执行
- 网关层增加熔断触发(Kong 的 upstream 健康检查)
另一个事故复盘:缓存雪崩导致的连锁故障:
某社交平台 2024 年春节活动,Redis 主节点宕机后哨兵选举耗时 12 秒,期间所有缓存查询直穿数据库。数据库连接池默认 50 个连接,被 12 秒内涌入的 10 万 QPS 打满。数据库 CPU 从 20% 飙到 95%,查询响应从 5ms 升到 15s。最终导致整个 feed 流服务不可用 18 分钟。
根因分析:
- Redis 主从切换期间缓存穿透无保护
- 本地缓存(Caffeine)没开启,所有请求都走远程缓存
- 数据库连接池无限流,10 万 QPS 全部打向数据库
- 数据库连接池无读写分离,读请求和写请求共享连接
改进措施:
- 所有服务增加本地缓存层(Caffeine),缓存时间 30s,即使 Redis 挂了也能撑 30s
- 数据库连接池增加队列和拒绝策略:超过 100 个并发请求直接返回降级数据
- 数据库连接池分读写:读库连接池 100 个,写库连接池 20 个
- Redis 主从切换期间,网关层主动降级非核心功能
// 多层缓存兜底策略
@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;
}
}总结
微服务雪崩不是"运气不好",是防御体系有缺口。四层防御层层递进:
| 层次 | 手段 | 作用 | 缺了会怎样 |
|---|---|---|---|
| 第一层 | 超时控制 | 单个请求不卡住线程 | 一个慢请求卡死所有线程 |
| 第二层 | 熔断 | 下游挂了,上游不拖死 | 慢服务扩散到整条链路 |
| 第三层 | 限流 | 提前挡住超量流量 | 流量尖峰打垮所有服务 |
| 第四层 | 全链路压测 | 发现未知问题,设定容量水位 | 上线后才发现扛不住 |
一句话:超时兜底,熔断断开,限流设防,压测验证。少一层,出事概率翻倍。
唯一可靠的策略是让每一层都能独立工作——不要指望网关层限流了就万事大吉,服务间调用也要配熔断。做过全链路压测的团队,处理线上事故的底气完全不一样。