4. 熔断与限流:熔断器模式、令牌桶算法、漏桶算法、滑动窗口限流、自适应限流
说到系统可靠性,熔断和限流是绕不开的两大护法。我见过太多系统,平时跑得挺欢,一遇到突发流量就直接「躺平」。说白了,熔断是防止故障蔓延,限流是防止资源耗尽。这两者配合好了,系统才能扛得住风浪。
4.1 熔断器模式:Hystrix 与 Resilience4j
熔断器模式,灵感来自电路里的保险丝。当某个下游服务连续出错,熔断器就会「跳闸」,直接拒绝请求,而不是让请求继续去撞南墙。等过一段时间,再尝试放行几个请求,看看下游是否恢复。
我在项目中用过 Hystrix,也用过 Resilience4j。Hystrix 是 Netflix 的老牌组件,功能很全,但已经进入维护模式了。我个人更推荐 Resilience4j,轻量、模块化,而且跟 Spring Boot 集成得更好。
核心状态机:
- CLOSED(关闭):正常状态,请求正常通过。当失败率达到阈值(比如 50%),进入 OPEN 状态。
- OPEN(打开):直接拒绝请求,快速失败。经过一个时间窗口(比如 5 秒),进入 HALF_OPEN 状态。
- HALF_OPEN(半开):放行少量请求,试探下游是否恢复。如果成功,回到 CLOSED;如果失败,回到 OPEN。
用 Resilience4j 实现一个熔断器,代码其实很简洁:
// 配置熔断器
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率阈值 50%
.waitDurationInOpenState(Duration.ofSeconds(5)) // 打开后等待 5 秒
.slidingWindowSize(10) // 滑动窗口大小 10 个请求
.build();
CircuitBreaker circuitBreaker = CircuitBreaker.of("myService", config);
// 使用熔断器包裹业务逻辑
Supplier<String> decoratedSupplier = CircuitBreaker
.decorateSupplier(circuitBreaker, () -> callDownstreamService());
Try.ofSupplier(decoratedSupplier)
.onFailure(throwable -> log.error("调用失败", throwable))
.getOrElse("fallback response");
避坑指南:我曾经在配置熔断器时,把滑动窗口设得太小,结果几个偶然的超时就把熔断器触发了,导致大量正常请求被拒绝。建议滑动窗口至少设为 20-30 个请求,避免「误伤」。
4.2 令牌桶算法
令牌桶算法,是我个人最常用的限流算法。它的思路很简单:系统以固定速率往桶里放令牌,每个请求来的时候,必须拿到一个令牌才能通过。如果桶里没令牌了,请求就被拒绝或排队。
你想想看,令牌桶的好处是什么?它能应对突发流量。因为桶里可以存一些令牌,平时流量低的时候,令牌就攒下来了。突然来一波流量,可以用这些积攒的令牌去「透支」一下,不会直接拒绝所有请求。
Guava 的 RateLimiter 就是基于令牌桶实现的:
// 每秒生成 10 个令牌,桶容量也是 10
RateLimiter limiter = RateLimiter.create(10.0);
// 尝试获取令牌,最多等待 100 毫秒
if (limiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
// 处理请求
processRequest();
} else {
// 限流处理
return "请求过于频繁,请稍后再试";
}
注意:令牌桶算法允许一定程度的突发流量,但突发量不能超过桶的容量。如果桶容量设得太大,可能会导致后端瞬间被打爆。我建议桶容量设为平均速率的 2-3 倍,既允许突发,又不至于失控。
4.3 漏桶算法
漏桶算法,跟令牌桶正好相反。它把请求比作水,桶底有个洞,水以固定速率漏出去。无论进来多少水,出去的速率都是恒定的。如果桶满了,新来的水就直接溢出(被拒绝)。
说白了,漏桶算法是「强制整形」,把不规则的流量变成平滑的流量。它适合那些对延迟不敏感、但必须保证处理速率的场景,比如日志写入、消息推送。
我记得有个项目,需要把用户操作日志写入数据库。如果直接用令牌桶,突发流量会导致数据库连接池瞬间打满。后来改用漏桶算法,把写入速率限制在每秒 100 条,数据库就稳了。
漏桶的简单实现:
public class LeakyBucket {
private final long capacity; // 桶容量
private final long leakRate; // 漏出速率(每秒)
private long water; // 当前水量
private long lastLeakTime; // 上次漏水时间
public synchronized boolean tryConsume() {
long now = System.currentTimeMillis();
// 先漏水:根据时间差计算漏掉了多少
long elapsed = (now - lastLeakTime) / 1000;
water = Math.max(0, water - elapsed * leakRate);
lastLeakTime = now;
if (water < capacity) {
water++;
return true; // 请求通过
}
return false; // 桶满了,拒绝
}
}
经验之谈:漏桶算法的缺点是,即使后端有能力处理更多请求,它也会强制限流,导致资源浪费。所以,如果后端处理能力有弹性,我更推荐令牌桶。漏桶适合那些「处理能力固定」的场景。
4.4 滑动窗口限流
滑动窗口限流,是对固定窗口限流的改进。固定窗口有个问题:比如限制每分钟 100 个请求,如果用户在 59 秒时发了 100 个,又在下一秒(下一分钟)发了 100 个,实际上 2 秒内就发了 200 个请求,这显然不合理。
滑动窗口把时间分成更小的格子(比如 1 秒一个格子),然后统计过去 N 个格子内的请求总数。这样就能更精确地控制流量。
我常用 Redis 的 Sorted Set 来实现滑动窗口:
// 伪代码:滑动窗口限流
String key = "rate_limit:user_123";
long now = System.currentTimeMillis();
long windowSize = 60 * 1000; // 窗口大小 60 秒
// 移除窗口外的旧数据
redis.zremrangeByScore(key, 0, now - windowSize);
// 统计当前窗口内的请求数
long count = redis.zcard(key);
if (count < 100) {
// 添加当前请求的时间戳
redis.zadd(key, now, String.valueOf(now));
redis.expire(key, 60);
return "允许通过";
} else {
return "请求被限流";
}
滑动窗口 vs 固定窗口:滑动窗口的精度更高,但代价是内存占用更大(需要存储每个请求的时间戳)。如果对精度要求不高,固定窗口 + 随机抖动也能凑合。我个人习惯在关键接口上用滑动窗口,非关键接口用令牌桶就够了。
4.5 自适应限流
前面几种限流算法,都需要手动配置阈值。但实际场景中,系统的处理能力是动态变化的——比如数据库连接池被占用了、GC 发生了、网络延迟变高了。这时候,固定的限流阈值就不太灵了。
自适应限流,就是让系统根据当前的负载情况,自动调整限流阈值。常见的思路有:
- 基于 CPU 使用率:CPU 超过 80% 时,自动降低限流阈值。
- 基于请求响应时间:如果平均响应时间超过 500ms,说明系统快扛不住了,主动限流。
- 基于 TCP 拥塞控制:借鉴 TCP 的 AIMD(加法增、乘法减)策略,动态调整限流窗口。
我参与过一个高并发项目,用的是阿里巴巴的 Sentinel。它支持自适应限流,核心逻辑是:
// Sentinel 自适应限流配置
FlowRule rule = new FlowRule("myService");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER);
// 设置自适应模式
rule.setAdaptive(true);
rule.setMaxQueueingTimeMs(500); // 最大排队时间
FlowRuleManager.loadRules(Collections.singletonList(rule));
曾经踩过的坑:自适应限流虽然智能,但调参很讲究。我记得有一次,自适应算法根据 CPU 使用率自动降级,结果因为 CPU 使用率波动太大,导致限流阈值频繁变化,反而引发了抖动。后来加了平滑滤波,才稳定下来。
4.6 如何选择?
说了这么多,到底该用哪种?我整理了一个表格,方便你对比:
| 算法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 熔断器 | 下游服务不稳定,防止故障蔓延 | 快速失败,保护整体系统 | 需要合理配置阈值,否则误伤 |
| 令牌桶 | 允许突发流量的场景 | 支持突发,实现简单 | 突发量不可控 |
| 漏桶 | 需要平滑流量的场景 | 流量绝对平滑 | 浪费后端处理能力 |
| 滑动窗口 | 需要精确限流的场景 | 精度高,避免边界问题 | 内存占用高 |
| 自适应限流 | 系统负载动态变化的场景 | 自动调整,无需人工干预 | 调参复杂,可能引发抖动 |
嗯,这里要注意:没有银弹。我通常的做法是,核心接口用熔断器 + 滑动窗口,非核心接口用令牌桶。如果系统负载变化大,再考虑引入自适应限流。关键是要先跑起来,再根据监控数据慢慢调优。
最后说一句,限流和熔断一定要配合监控。没有监控的限流,就像蒙着眼睛开车——你根本不知道限流阈值设得合不合理。我习惯在限流触发时打日志、上报指标,这样就能知道「什么时候限流了、限了多少」,后续调优才有依据。