一、服务雪崩与容错设计

大家好,我是你们的老朋友。今天咱们聊聊分布式系统里一个绕不开的话题——服务雪崩。说实话,我见过太多团队在这个坑里栽跟头了。我自己也踩过,嗯,那滋味可不好受。

1.1 什么是服务雪崩

先讲个故事。几年前我负责一个电商系统,大促期间,订单服务突然变慢。按理说,这只是个小问题。但可怕的是,订单服务一慢,依赖它的支付服务开始排队等待。支付一堵,库存服务也卡住了。库存卡住,商品详情页跟着挂。最后,整个网站都瘫了。

这就是服务雪崩。说白了,就是一个服务的故障,像多米诺骨牌一样,一个接一个地传染给其他服务。你想想看,一个节点挂了,调用方还在疯狂重试,结果把调用方也拖垮了。调用方一垮,调用方的调用方也遭殃。最终,整个系统都崩了。

核心特征:

  • 传播性:故障沿着调用链向下游扩散
  • 级联性:一个故障引发多个服务连锁失效
  • 不可控性:一旦开始,很难人工干预停止

为什么会这样?我总结了两点:

  1. 资源耗尽:下游服务慢了,上游的线程池、连接池全被占着不放。新请求进不来,旧请求出不去。
  2. 重试放大:调用方发现超时,本能地重试。结果呢?流量反而翻倍了。下游本来还能喘口气,这下直接窒息。

1.2 为什么需要容错

有人可能会问:「我把每个服务都做高可用不就行了?」

想法很好,但现实很骨感。我在项目中遇到过,哪怕你99.99%可用,一个调用链上有10个服务,整体可用性就掉到99.9%了。更何况,网络抖动、机房断电、代码bug,这些你根本防不住。

容错设计的核心思想就一句话:接受故障是常态,设计系统去优雅地处理它。而不是幻想系统永远不会出问题。

我的经验:容错不是锦上添花,而是保命手段。没有容错的分布式系统,就像没有安全气囊的跑车——跑得快,但撞了就完蛋。

1.3 常见的容错策略

好,理论说完了,咱们上干货。容错策略有五种,我一个个讲。每个我都踩过坑,你们记好。

1.3.1 超时控制

这是最基础、也最容易忽略的策略。很多新手写代码,HTTP调用不设超时,或者设个60秒。你想想看,一个请求等60秒,线程池不炸才怪。

我个人习惯:超时时间设为TP99的2-3倍。比如接口99%的请求在200ms内完成,我就设500ms超时。再长就不等了,直接失败。

// 错误示范:不设超时
RestTemplate restTemplate = new RestTemplate();
String result = restTemplate.getForObject(url, String.class);

// 正确做法:显式设置超时
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(500);
factory.setReadTimeout(500);
RestTemplate restTemplate = new RestTemplate(factory);

避坑指南:我曾经见过一个团队,超时设了30秒,结果数据库慢查询导致所有线程都在等。30秒后超时了,但线程池已经满了,新请求全部拒绝。记住:超时不是越大越好,要结合业务容忍度来设。

1.3.2 重试机制

重试是把双刃剑。用好了能提高成功率,用不好就是雪崩的加速器。

我建议遵循三个原则:

  • 限制次数:最多重试1-2次,别无限重试
  • 指数退避:每次重试间隔翻倍,比如第一次等100ms,第二次等200ms
  • 只对幂等接口重试:写操作重试可能导致重复下单、重复扣款
// 带指数退避的重试
int retryCount = 0;
int maxRetries = 2;
long baseDelay = 100;

while (retryCount <= maxRetries) {
    try {
        return callRemoteService();
    } catch (Exception e) {
        retryCount++;
        if (retryCount > maxRetries) throw e;
        Thread.sleep(baseDelay * (1 << retryCount)); // 100, 200, 400...
    }
}

1.3.3 限流

限流就是给系统装个水龙头。流量太大时,主动拒绝一部分请求,保护系统不被冲垮。

常见的限流算法有四种:

算法 原理 适用场景
固定窗口 统计1秒内的请求数,超过阈值就拒绝 简单场景,但存在临界突变问题
滑动窗口 把时间窗口切分成小格子,滑动统计 比固定窗口平滑,适合大部分场景
漏桶算法 请求先进入桶里,以固定速率流出 保护下游,削峰填谷
令牌桶算法 以固定速率生成令牌,请求拿到令牌才能通过 允许突发流量,最常用

我个人最常用令牌桶。为什么呢?因为它既能限流,又能允许短时间内的突发流量。比如系统支持1000 QPS,突然来了2000个请求,令牌桶会先放行一部分,剩下的排队等令牌。而不是一刀切全部拒绝。

1.3.4 降级

降级就是主动放弃一些非核心功能,保证核心功能可用。说白了,就是「保命要紧,面子先放一放」。

我在项目中遇到过,双11大促时,商品详情页的「猜你喜欢」推荐接口扛不住了。怎么办?直接降级,返回空列表。用户看不到推荐,但能正常下单付款。核心链路保住了。

降级策略分类:

  • 读降级:返回缓存数据或默认值,不查数据库
  • 写降级:异步写入消息队列,不实时落库
  • 功能降级:关闭非核心功能,比如评论、点赞

1.3.5 熔断

熔断是容错的最后一道防线。它的思路很简单:如果下游服务连续出错,就主动切断调用,让下游有时间恢复。就像家里的电路,电流过大时保险丝熔断,保护电器不被烧坏。

熔断器有三个状态:

  • 关闭:正常状态,请求正常通过
  • 打开:错误率达到阈值,直接拒绝请求,快速失败
  • 半开:过了一段时间,放少量请求试探,看下游是否恢复
// 使用 Resilience4j 实现熔断
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)           // 错误率超过50%触发熔断
    .waitDurationInOpenState(Duration.ofSeconds(10))  // 熔断持续10秒
    .slidingWindowSize(10)              // 统计最近10次请求
    .build();

CircuitBreaker circuitBreaker = CircuitBreaker.of("orderService", config);

// 被熔断保护的调用
Supplier<String> decorated = CircuitBreaker.decorateSupplier(
    circuitBreaker, () -> orderService.getOrder(orderId)
);

// 如果熔断打开,会抛出 CircuitBreakerOpenException
String result = Try.ofSupplier(decorated)
    .recover(e -> "fallback result")
    .get();

我的建议:熔断和降级经常搭配使用。熔断触发后,走降级逻辑返回兜底数据。这样既保护了下游,又不影响上游的体验。

1.4 五种策略如何选

很多同学问我:「这么多策略,我该用哪个?」

我的回答是:全都要,但要有主次

给你一个参考组合:

  • 超时:所有外部调用必须设,这是底线
  • 重试:只对幂等接口、且配合指数退避使用
  • 限流:入口处必加,防止流量突增
  • 降级:非核心链路必加,保核心功能
  • 熔断:核心依赖必加,防止级联故障

嗯,这一章就到这里。记住一句话:容错不是让系统不出错,而是让系统在出错时还能优雅地运行。下一章咱们深入讲讲限流的具体实现,包括Guava RateLimiter和Sentinel的对比。到时候见。