系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL
- Kafka(上):吞吐的来源是顺序 IO
- Kafka(下):生产端、broker 与消费端的可靠性配置
- RPC 框架:像本地调用一样调远程的代价
- 消息语义:按 at-least-once 设计业务代码
- 熔断与限流中间件:把失败当作正常状态管理(本篇)
2016 方案的局限与 2019 的中间件能力
(超时重试限流降级)介绍过超时、重试、限流、降级四项机制。那篇写的限流是单机内存里按秒重置的计数器:每秒一个计数,到阈值拒绝,秒切换时清零。它在云盘入口用了很久,也暴露两个问题。
一是窗口边界的临界突变。固定窗口在 0.9 秒放过阈值、1.1 秒又放过阈值,0.2 秒内实际通过量接近两倍阈值。秒级监控的 QPS 可以保持平稳,亚秒级仍会出现毛刺。二是它只统计到达速率,不反映在途并发;慢接口占住线程时,新请求仍按秒计数放行,直到线程池拒绝策略触发才停止,中间的排队延迟不受这个计数器限制。
那篇结尾记录过自动熔断的缺失:下游持续故障时,需要人工开启降级开关,处置以分钟计。手写计数器只能做粗粒度的到达控制,无法消除临界毛刺,也不能在下游持续失败时自动停止调用。到 2019 年,公司的服务治理栈已将熔断、限流做成可配置的中间件能力。本文介绍这些能力的实现机制,以及「隔离优先」和「控制优先」两种方案的适用条件。
关于 Hystrix 与 Sentinel 的素材说明。 2016 年,我只阅读了 Hystrix wiki 并在本地运行 demo,未在生产环境使用(2016 系列第 13 篇末尾已标注);2019 年整理时仍以官方文档和源码阅读为主,未做生产验证。本文写作时,Sentinel 仅在本地搭建过 demo,生产使用待确认;下文涉及流量整形的内容属于学习认知。本文讨论熔断器和限流器作为中间件的实现机制,包括状态机迁移、窗口统计和资源隔离,不重复前文对四项机制必要性的说明。
熔断器状态机
熔断器记录下游持续失败,并据此改变调用方的状态。三态之间的迁移由滑动窗口中的失败率驱动。
CLOSED(闭合)。 正常状态,请求放行,滑动窗口在后台累计每次调用的成功与失败。统计口径通常是失败率(失败数 / 总请求数),也有实现叠加慢调用比例。失败率低于阈值时维持闭合。
CLOSED → OPEN(打开)。 失败率超过阈值时熔断器打开。打开之后请求不再发往下游,直接快速失败或走回退。下游持续故障时,这能避免调用方继续消耗线程等待响应。RPC 框架:像本地调用一样调远程的代价讲 RPC 时提过「调用线程阻塞在 socket read 上拖垮线程池」;熔断器打开后,这类调用不再进入该阻塞路径。
OPEN → HALF_OPEN(半开)。 OPEN 状态持续到冷却窗口(sleep window)到期,此后进入半开态。半开时只放行少量试探请求(Hystrix 默认一次),其余仍快速失败。试探结果决定下一步:成功达到条件时回到 CLOSED,失败时重新回到 OPEN 并重置冷却窗口。
状态机包含两个独立的计时机制。滑动窗口由事件触发:失败率超过阈值即打开,统计持续进行。冷却窗口由时间触发:到期即进入半开态,不依赖统计值。HALF_OPEN 的试探结果单独判定,不参与 CLOSED 态滑动窗口的失败率计算(Hystrix 1.5 中试探调用本身仍被记入 rolling metrics,HALF_OPEN→CLOSED/OPEN 的决策依据为单次试探结果,窗口失败率不参与该判断;Sentinel 1.x 的半开实现随版本演进有变化,本文不展开)。
滑动窗口如何计算失败率。 直接的实现是用定长队列记录最近 N 次调用,并在每次调用后计算失败率,但全量遍历的开销较大。Hystrix 用桶分割加环形数组:把统计窗口按时间切成 N 个桶(例如 10 秒窗口切 10 个 1 秒桶),每个桶独立记录成功数和失败数,窗口总值是当前 N 个桶之和。桶随时间滚动;每过一秒,最旧的桶清零复用为最新桶。环形数组使旧数据随时间过期,查询失败率只需累加固定个数的桶。代价是精度损失:桶内数据只汇总到桶粒度,无法还原单次调用时序。这种统计适用于失败率计算,不适用于需要按调用序号回溯的场景。
限流算法
熔断器在下游持续失败后减少调用,限流器在请求量超过系统处理能力时限制请求。以下四种常见算法处理突发流量的方式不同,选型时需要确认下游可接受的流量形态。
固定窗口计数器。 就是 2016 篇中按秒重置的计数器。它只维护每个窗口的计数,窗口边界的临界突变是固有缺陷:边界前后各放过阈值时,实际瞬时流量翻倍。适合对突发不敏感、只需要粗粒度总量控制的入口。
滑动窗口计数器。 把固定窗口再细分为若干子桶(例如 1 秒窗口切 4 个 250ms 子桶),当前计数是窗口覆盖到的各子桶之和;另一种实现取上一完整窗口的计数,按当前窗口已过去的时间比例加权折算。两路都平滑了临界突变,仍有子桶粒度的边界毛刺,只是粒度更细。Sentinel 默认限流统计走子桶求和这条路(LeapArray,秒级窗口默认切 2 个 500ms 子桶)。
令牌桶。 系统以固定速率向容量有限的桶中加入令牌;请求到达时取一个令牌,取不到就拒绝。空闲期生成的令牌会积累到桶容量,一波突发可以消耗这些存量令牌,直到桶空后才按生成速率限速。令牌桶允许突发,突发上限是桶容量,适合平时平稳、偶尔脉冲的流量。
漏桶。 请求先进入桶等待,桶以固定速率将请求放出执行。无论输入速率如何,输出速率保持恒定。漏桶不允许突发,桶满后直接拒绝。它适合保护必须匀速处理的下游。Guava 的 RateLimiter 是令牌桶变体(支持预热),不是漏桶。
令牌桶和漏桶的差异在于是否允许突发:令牌桶允许短时超速(消耗存量令牌),漏桶强制恒速(多余请求直接拒绝)。选择取决于下游能否接受突发。
资源隔离的两种方式
除熔断和限流外,Hystrix 还提供资源隔离。某个下游变慢时,资源隔离限制其对其他下游调用的影响。Hystrix 提供两种隔离方式。
线程池隔离。 每个下游依赖分配独立线程池,调用在对应池中执行。池子有上限和队列上限,满了直接拒绝(走回退)。某个下游变慢并使其线程池达到容量上限时,影响范围限制在该线程池内,其他下游的调用不受影响。代价是线程开销:每次调用都可能排队和切换线程,上下文切换与调度开销会增加调用链路延迟。Hystrix 默认推荐线程池隔离。
信号量隔离。 不开新线程,调用在当前线程执行,只用信号量限制并发数。拿到信号量才执行,拿不到就拒绝。它没有线程切换开销,也没有超时脱离能力:下游长期阻塞时,当前线程同样阻塞,信号量只能限制并发数,不能强制中止调用。信号量隔离适合调用本身很快、不太可能长期阻塞的场景(纯内存调用或快速缓存读取),不适合可能长时间阻塞的远程调用。
线程池隔离增加线程与调度开销,并能将超时调用从调用线程中脱离;信号量隔离开销较低,但没有超时脱离能力。Hystrix 的线程池隔离同时处理超时和隔离:coreSize 与 queueSizeRejectionThreshold 决定隔离边界,execution.isolation.thread.timeoutInMilliseconds 决定超时。信号量模式下超时配置不生效,这是文档明确的限制。
一个 Hystrix 命令的最小写法:
public class GetUserCommand extends HystrixCommand<User> {
private final Long userId;
public GetUserCommand(Long userId) {
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("UserService"))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter()
.withCircuitBreakerErrorThresholdPercentage(50) // 失败率阈值 50%
.withCircuitBreakerSleepWindowInMilliseconds(5000) // 冷却 5 秒
.withExecutionTimeoutInMilliseconds(800))); // 超时 800ms
this.userId = userId;
}
@Override
protected User run() { return userService.getUser(userId); }
@Override
protected User getFallback() { return User.degraded(userId); }
}
// 调用:new GetUserCommand(123L).execute();withCircuitBreakerErrorThresholdPercentage 是 CLOSED→OPEN 的阈值,withCircuitBreakerSleepWindowInMilliseconds 是冷却窗口。信号量隔离把 withExecutionIsolationStrategy 改成 SEMAPHORE,此时超时配置不再生效。
Sentinel 的规则模型与流量整形
Sentinel(阿里巴巴 2018 年开源,本文口径为 1.x)与 Hystrix 的侧重点不同。Hystrix 为下游依赖分配线程池,以限制故障影响范围,并在此基础上提供熔断和限流;Sentinel 将熔断、限流和流量整形统一配置为规则。
规则模型。 Sentinel 用资源和规则配置保护行为。资源是一段被保护的代码(用 SphU.entry 包裹),规则关联到资源,类型包括流控、熔断、系统、热点规则。规则可热更新,并通过 DataSource(文件、ZK、Nacos 等)推送。资源标识需要保护的代码,规则定义保护方式;因此规则可以集中更新,而 Hystrix 的规则写在 Command 构造器中。
一个 Sentinel 资源保护的最小写法:
// 定义资源
try (Entry entry = SphU.entry("getUser")) {
return userService.getUser(userId); // 被保护的调用
} catch (BlockException e) {
return User.degraded(userId); // 被流控或熔断时走这里
}
// 流控规则通过控制台或 DataSource 推送:QPS 阈值、熔断失败率阈值等Sentinel 的流量整形策略。 Hystrix 的限流是拒绝式:超限后走回退,不排队。Sentinel 除拒绝式外还提供匀速排队(RateLimiterController)和预热(WarmUpController)两种策略。匀速排队近似漏桶语义:请求在入口按固定速率放行,多余请求排队等待,适合脉冲削峰。预热模式在流量上升时先限制为较小流量,再逐步放开到阈值,以避免冷启动时请求量立即达到容量上限。
Hystrix 通过线程池隔离下游依赖,代价是线程开销。Sentinel 通过统一规则配置流量控制,流量整形会使请求在入口排队并占用入口资源。下游调用可能长期阻塞时,需要优先评估隔离;突发流量可能超过容量时,需要优先评估流量控制。
限流配置的学习记录
这一节按 B 级素材处理,复盘细节待确认,只写学习认知判断。
限流阈值需要依据压测确定。2016 系列第 10 篇讲过压测与容量评估(压测与容量评估),单机容量拐点需要通过压测确定。阈值通常取压测拐点的安全余量(例如拐点的 70%-80%)。单接口单机压测不能直接代表线上的混合负载;混合负载下实际容量低于单接口压测值,按单接口设阈值会偏乐观。
误伤的发现靠监控。限流生效后拒绝率指标会暴露哪些请求被拒。一种典型误伤:阈值按整体 QPS 设置,某个调用方瞬间用尽配额,其他正常调用被一起拒绝。处理方式是按调用方拆分配额(Sentinel 的热点参数或来源限流能做),或把批量接口限流单独拆出去。判断依据是监控里拒绝请求的来源分布。以上为学习认知,未经生产确认。
实现边界
- 熔断器直接保护调用方资源。 熔断器打开后,调用方快速失败并保留自己的线程与连接。下游的容量问题仍需要由下游自身限流和扩容处理;调用方熔断不能替代下游保护。
- 限流阈值需要压测依据。 阈值应取压测拐点的安全余量,并确认压测是单接口还是混合负载。混合负载下实际容量低于单接口压测值,按单接口设阈值会偏乐观。这一条呼应 2016 系列第 10 篇。
- 失败率统计存在窗口滞后。 滑动窗口汇总过去 N 秒的失败率。下游刚恢复时,窗口仍包含此前的失败记录,熔断器可能继续保持打开。半开态通过试探请求确认下游是否恢复,但试探本身需要等待冷却窗口。
- 线程池隔离会增加调用延迟。 线程切换和排队开销需要纳入调用链路延迟。对延迟敏感且下游可控的调用,可以使用信号量隔离,但它没有超时脱离能力。
- 令牌桶和漏桶适用不同的流量形态。 下游可接受突发时可用令牌桶削峰;必须匀速处理时可用漏桶。将两者混用,无法得到预期的流量形态。
- 限流和熔断处理不同指标。 限流处理总量,熔断处理失败率。总量未超限但失败率升高时需要熔断;失败率正常但总量超限时需要限流。使用限流代替熔断会在下游故障时继续按阈值放行;使用熔断代替限流会在容量不足时反复开关熔断器,放行的请求仍可能使下游达到容量上限。
参考资料
- Netflix Hystrix wiki(1.5 版本,熔断器状态机、滑动窗口桶分割、线程池与信号量隔离、超时语义)
- Sentinel 官方文档(1.x,资源与规则模型、匀速排队与预热、DataSource 推送)
- 超时重试限流降级(2016 系列第 13 篇,本篇引用不复述)
- 压测与容量评估(2016 系列第 10 篇,限流阈值的压测依据)
- RPC 框架:像本地调用一样调远程的代价(调用线程阻塞与超时配置)
