Sentinel源码解析:滑动窗口与规则引擎
场景:一个接口被刷爆了
生产环境有个订单接口,平时QPS 200,某天凌晨被脚本刷到2000,CPU直接飙红。限流阈值设的500,但Sentinel没拦住——因为阈值配的是QPS,而脚本是短时间脉冲式请求,1秒内冲到2000,Sentinel的统计窗口还没反应过来就放过去了。
这就是Sentinel滑动窗口要解决的核心问题:如何在毫秒级感知流量突变,而不是等统计周期结束才反应过来。
滑动窗口:不是"一整块"时间,是"切碎的"时间
Sentinel的统计结构是MetricBucket,每个bucket存1秒内的通过数、拒绝数、耗时等指标。LeapArray是核心容器,它维护一个环形数组,每个元素是一个bucket。
// LeapArray的核心:根据当前时间计算应该在哪个槽位
public int calculateTimeIdx(long timeMillis) {
long timeId = timeMillis / windowLengthInMs;
return (int)(timeId % array.length());
}
关键点:
windowLengthInMs是每个窗口的毫秒数,默认1000msarray.length()是窗口总数,默认2(即一个1秒的统计周期被切成2个500ms的窗口)- 这行代码算出当前时间戳落在哪个槽位,槽位是循环复用的
问题来了:如果窗口是固定的,那1秒内的前500ms和后500ms是"两个世界"。QPS=2000如果集中在第400ms~600ms,前500ms窗口只统计到400ms的流量,后500ms窗口统计到600ms的流量,每个窗口都不到500,但1秒内实际是2000。
Sentinel的解法:当前时间戳取模后,如果发现槽位已经过期,就重置bucket再写入。
public WindowWrap<T> currentWindow(long timeMillis) {
int idx = calculateTimeIdx(timeMillis);
long windowStart = timeMillis - timeMillis % windowLengthInMs;
// 循环获取,如果为空就创建,如果过期就重置
while (true) {
WindowWrap<T> old = array.get(idx);
if (old == null) {
// CAS创建新窗口
} else if (windowStart == old.windowStart()) {
return old;
} else if (windowStart > old.windowStart()) {
// 窗口过期,重置
if (updateLock.tryLock()) {
old.resetTo(windowStart);
return old;
}
}
}
}
这里的resetTo是性能关键点:不是new一个新bucket,而是复用旧对象,重置时间戳和计数。避免了频繁GC。
规则引擎:从FlowRuleChecker到Tracer的链式调用
限流规则不是直接写在FlowRule里的。Sentinel的规则引擎分三层:
ProcessorSlotChain:责任链模式,每个slot负责一件事。FlowSlot负责限流,DegradeSlot负责熔断,StatisticSlot负责统计。FlowRuleChecker:对每个通过的请求,检查是否触发规则。Tracer:记录异常,供熔断判断。
// FlowSlot的entry方法
public void entry(Context context, ResourceWrapper resourceWrapper, ...) {
FlowRuleChecker.checkFlow(resourceWrapper, context, node, count, prioritized);
// 检查通过才放行
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
核心逻辑在FlowRuleChecker.canPassCheck:
public boolean canPassCheck(/* ... */) {
// 遍历所有规则
for (FlowRule rule : rules) {
if (!canPassByParam(rule, context, node, count)) {
return false;
}
}
return true;
}
每个FlowRule内部有一个TrafficShapingController,这是个策略接口。不同限流算法对应不同实现:
DefaultController:直接比较当前QPS和阈值ThrottlingController:排队等待,匀速通过WarmUpController:冷启动,阈值从低到高渐变
为什么这样设计? 把"统计"和"判断"解耦。统计是StatisticSlot的事,判断是FlowRuleChecker的事,规则算法是TrafficShapingController的事。你要加一种新限流算法,只需要实现TrafficShapingController,不用碰统计链路。
熔断:状态机+时间窗口
熔断比限流复杂一点。限流是"超了就拒",熔断是"失败多了就断开,过一会儿再试"。
Sentinel的DegradeSlot维护一个CircuitBreaker状态机:
CLOSED -> OPEN -> HALF_OPEN -> CLOSED
CLOSED:正常,所有请求放行OPEN:熔断打开,所有请求快速失败HALF_OPEN:半开,放一个探针请求,成功则关断,失败则继续打开
核心实现是ExceptionCircuitBreaker,它内部用LeapArray统计"最近N秒的异常比例":
public boolean tryPassCurrentNode() {
// 从统计窗口拿最近的数据
double exceptionRatio = getExceptionRatio();
if (exceptionRatio < maxAllowedRatio) {
return true; // 未超过阈值,放行
}
// 超过阈值,进入OPEN状态
return false;
}
熔断状态切换的触发点:不是每次请求都判断状态,而是DegradeSlot在entry时先检查状态,OPEN直接拒绝,CLOSED则记录异常并判断是否要切换到OPEN。
踩过的坑
- 窗口大小和阈值要匹配。如果阈值是500 QPS,窗口是1000ms,那500ms内的脉冲流量是测不出来的。把
windowLengthInMs调小到200ms,会有更细粒度统计,但代价是更多bucket,GC压力变大。 - 规则热更新。Sentinel的规则存在
RuleManager里,用volatile引用+CopyOnWrite更新。但FlowRuleChecker每次检查都遍历规则列表,如果规则很多(几百条),每次请求都遍历会损耗性能。建议规则数量控制在50条以内。 - 熔断的恢复时间。
HALF_OPEN状态下只放一个探针请求,如果这个请求恰好是慢请求(比如超时5秒),那后续所有请求都要等这个探针完成才能判断状态。生产环境把maxWaitTime调小,避免探针阻塞太久。
核心收获
Sentinel的限流熔断本质是"时间窗口+状态机"的组合。滑动窗口解决"怎么统计",规则引擎解决"怎么判断",熔断状态机解决"怎么恢复"。三者解耦,各自独立演进。
下一步可以试:读SentinelDashboard的源码。看控制台怎么把规则推送到客户端(RuleManager的加载机制),以及客户端怎么上报统计数据(SentinelTransport)。这会让你理解限流组件在分布式场景下的控制面和数据面分离设计。