☰
Sentinel核心架构源码解析:限流算法与Slot责任链实战
2026/10/12 5:14:03 网站建设 项目流程

线上服务最怕什么?不是代码写得烂,而是流量突然失控,数据库连接被打满、缓存超时、线程池拒绝,最后全链路像多米诺骨牌一样倒下。限流算法我接触过不少,计数器、滑动窗口、令牌桶、漏桶各有各的适用场景,而要把这些算法塞进一个生产可用的组件里,还得有一套灵活的扩展骨架,Sentinel给出的答案是Slot责任链。这篇文章不打算停留在使用层面,我会从源码出发,把Sentinel核心架构从头到尾拆一遍:限流算法是怎么实现的、滑动窗口内部长什么样、责任链又是怎么把限流/熔断/系统保护串起来执行。无论你是准备在项目里接入限流组件,还是想把中间件源码当作系统设计范本来读,这篇都值得看完。

1. 核心架构的起点:资源、规则与上下文

1.1 从一次线上事故说起

我印象很深的一次故障,是某个商品详情接口平时只有两三百QPS,大促预热时流量突然顶到三千多。网关层明明配了限流,但限流阈值是按单机带宽和总入口QPS估算的,根本挡不住业务内部对下游Redis、数据库的突发调用。等到发现慢查询堆积,数据库连接池已经被耗尽,紧接着就是依赖这个接口的其他服务跟着超时,整个调用链雪崩。事后复盘,问题不在网关限流不够强,而在于限流粒度太粗。

网关只能拦到“入口流量”,管不到你服务内部第三方RPC调用、数据库访问、甚至某个耗时奇高的私有方法。真正要稳住系统,限流点必须能埋到资源级别——这里的“资源”不是Linux里的概念,而是你想要保护的一段代码路径。Sentinel做得比较好的,就是允许你把任何一段Java代码定义成资源,给这段资源配置自己的限流、熔断和降级规则,全部在应用内完成,粒度精确到方法和调用来源。这也是我看中它的根本原因。

1.2 四个必须吃透的概念

读Sentinel源码之前,建议先把这几个核心对象的关系理顺,不然后面看Slot链会很吃力。

  • Resource(资源):一段需要保护的逻辑,可以是方法、RPC调用、SQL操作,甚至是URL。代码里用一个ResourceWrapper包装起来,包含资源名、入口类型等元信息。
  • Rule(规则):针对资源定义的约束,比如“这个资源每秒最多允许100个请求”,对应FlowRule。还有熔断降级规则DegradeRule、系统保护规则SystemRule、黑白名单规则AuthorityRule。
  • Context(上下文):一次请求或一次业务调用的全局上下文。它记录了当前调用链的入口、来源应用、当前Entry等信息,是串联整条链路状态的线索。
  • Entry(入口凭证):进入一个资源时创建的凭证,保存了资源对应的Node、调用来源、上下文等信息。想理解Sentinel,可以先记住一句话:SphU.entry()就是“申请通过”,exit()就是“申请结束”,所有限流、熔断的判定都发生在这两次调用之间的责任链执行过程里。

这四个对象的关系可以这样理解:来了一个请求,先根据资源名找到资源,再检查该资源配置了哪些规则,然后沿着责任链挨个用实时统计数据去校验规则,能通过就返回一个Entry,放你进去执行业务逻辑;不能通过就抛BlockException,由你决定是降级返回还是抛给上层。把这条主线拎住,后面所有源码细节都是往这个骨架上挂肉。

2. 限流算法:从计数器到滑动窗口

2.1 固定窗口计数器的硬伤

很多人第一次写限流,都是从固定窗口计数器开始的:取一个起始时间戳,以一秒或一分钟为窗口,窗口内请求数累加,超过阈值就拒绝,窗口结束就重新计数。逻辑简单到一分钟就能写完,但生产环境一压测就会暴露问题——窗口临界突变。

假设阈值是100次/秒,某个请求在59.9秒来了100次,下一秒0.1秒又来了100次。站在单个窗口看,两秒各自的计数都没超阈值,但真实情况是这0.2秒内已经打进来了200个请求,服务直接被瞬间峰值打垮。固定窗口的统计口径是“以窗口起始点划分”,它天然假设流量是均匀分布的,可线上流量偏偏就是突发的、不均匀的,所以真正生产可用的限流统计必须把窗口切得更细,让“窗口”能平滑地跟着时间滑过去。

2.2 滑动窗口的内部实现

Sentinel的每秒统计没有用固定窗口,而是用了一个叫LeapArray的环形数组。它的思路比较容易理解:把整个统计周期切成若干个小格子,每个格子独立记录这个格子时间段内的指标,比如PassQps、BlockQps、成功数、异常数、RT总和。判断是否超限时,不是只取当前这一格的数据,而是把“从当前格往前推整个周期长度”的所有格子数据加起来。

以intervalInMs=1000毫秒、sampleCount=10为例,每一格长度就是100毫秒。整个周期10格,对应环形数组容量10。来一个请求,先用当前毫秒时间戳计算它落在哪个格子:格子下标 = (时间戳 / 格子长度) % 数组长度。如果当前时间与格子的windowStart之差已经超过了一个格子周期,说明这格是过期数据,会被重置后复用。统计当前周期总量,只需要从当前格开始逆时针把10格全部累加,这样限流判断用的就是“最近一秒”的真实流量,而不是“从秒开始到现在”的流量。

这种做法有两个明显好处。第一,窗口是平滑滑动的,不存在固定窗口的临界翻倍问题;第二,数组中每个格子可以复用,不需要每毫秒都创建新对象,内存和GC压力都很小。源码里WindowWrap保存了windowStart和windowLength,内部value就是MetricBucket,里面用LongAdder维护各种计数项。之所以用LongAdder而不是AtomicLong,是因为限流场景是典型的并发读多写多场景,LongAdder在高并发下的吞吐量更稳,代价是牺牲了一点点一致性,但这个场景不需要强一致。

2.3 令牌桶、漏桶与三种控制行为

光会统计还不够,Sentinel对“超过阈值之后怎么处理”做了三种控制行为,分别对应不同的算法思想。理解透这段,你看FlowSlot时就不会一头雾水。

  • 快速失败(控制行为0):默认模式,就是直接拒绝。实现上就是上面说的滑动窗口计数器,当前周期累加值超过阈值立刻拒绝。
  • Warm Up(控制行为1):预热模式。生产环境经常碰到“系统刚启动,缓存还没热起来,直接放满量一定会把服务打垮”的问题,所以不能让系统一上来就承受顶峰流量。这个模式用的是令牌桶的思想,但桶的容量和填充速率会从较小的冷启动值平滑增长到设定阈值,等系统运行一段时间后才允许满负荷流量。
  • 排队等待(控制行为2):匀速排队模式,用的是典型的漏桶思想。所有超过阈值的请求不直接拒绝,而是按固定间隔排队放行。比如阈值100 QPS,每个请求就要间隔10ms依次通过,允许设置最长排队超时时间。这种模式适合需要削峰填谷的场景,比如上游的写入任务会瞬时突增,但下游只能以恒定速率消费。

有人会问,令牌桶和漏桶到底有什么区别。简单说,令牌桶看的是“桶里有没有令牌”,允许一定程度的突发流量,只要桶里攒的令牌够就能一下放行;漏桶看的是“出口流速”,无论如何流量都只能匀速流出,突发再猛也不会改变出口速率。Sentinel在Warm Up里用令牌桶是为了“缓启动”,在排队等待里用漏桶是为了“平峰谷”,各自用途不同,不要混为一谈。

3. Slot责任链设计

3.1 为什么需要责任链而不是一堆if

Sentinel要处理的事不少:统计指标、黑白名单、系统保护、限流、熔断降级、异常日志。如果全部写在一个类里,随着规则类型增加,这个类很快就会变成几百上千行的“上帝类”,每加一种能力都要改动既有代码,风险非常高。它选择的是责任链模式,把每个独立能力封装成Slot,一个Slot只管一件事,Slot之间通过链表结构顺序执行。

责任链让整个架构有了极好的扩展性。你想加一种新的检查逻辑,不需要改限流Slot,不需要改熔断Slot,只要实现自己的Slot,然后在构建链时插进指定位置即可。链上一个Slot要么放行让请求继续往next传,要么抛BlockException中断整条链。这个“要么放行、要么阻断”的契约非常干净,也是Sentinel能支持规则持续扩展的基础。

3.2 SPI机制与SlotChainBuilder装配

责任链是怎么组装出来的?Sentinel没有把这些Slot硬编码在业务代码里,而是给SlotChainBuilder提供了SPI加载入口。默认实现是DefaultSlotChainBuilder,读源码时你可以看到一个很经典的节点串联代码,大概逻辑是:先构建一个首节点处理器,然后依次将各Slot实例通过引用关系连成链表,每个Slot都继承AbstractLinkedProcessorSlot,内部持有next指针,fireEntry方法负责把调用传递给下一个Slot。

这里要特别提醒:不同版本构建Slot的顺序可能会有细微差别,我以自己常用的稳定分支为例,默认链路大致是NodeSelectorSlot、ClusterBuilderSlot、LogSlot、StatisticSlot、AuthoritySlot、SystemSlot、FlowSlot、DegradeSlot。这个顺序不是随便排的,前面Slot的统计结果,后面Slot要做判定时才能用它。StatisticSlot必须在FlowSlot之前,因为FlowSlot需要拿到实时统计值和线程占用数;AuthoritySlot和SystemSlot放在较靠前的位置,也是一样的逻辑——先做拦截类检查,再做资源消耗更复杂的限流判定。

3.3 责任链中各Slot的职责分工

先对每个默认Slot做一个速览,后续章节再逐个深入。

  • NodeSelectorSlot:负责构建调用树,为当前资源和调用链创建DefaultNode;
  • ClusterBuilderSlot:负责为同一个资源聚合ClusterNode,把不同入口来的流量汇总;
  • LogSlot:负责打印日志,包括故意记录到日志文件的Block日志;
  • StatisticSlot:负责更新实时统计指标,是所有规则判断的数据来源;
  • AuthoritySlot:负责黑白名单校验,判断当前来源是否被允许;
  • SystemSlot:负责系统自适应保护,根据系统负载、CPU等全局指标决定是否拦截;
  • FlowSlot:负责普通流量规则校验,限流逻辑主要在这里;
  • DegradeSlot:负责熔断降级规则校验,维护每个资源的断路器状态。

你会发现,Slot之间几乎没有业务耦合,各自只依赖责任链上下文和Node数据。这也是阅读源码最舒服的地方:每个Slot都可以独立打开,单看一个不会“牵一发动全身”。

4. 核心Slot逐个击破

4.1 NodeSelectorSlot:把调用关系织成树

NodeSelectorSlot看起来不起眼,但它是整个统计体系的基石。它要做的事是:一个资源在一个上下文里,只能有一个对应的DefaultNode实例。

源码里它维护了一个类似Map的结构,以“上下文名 + 资源对象”为维度缓存已经创建过的DefaultNode。每次请求进来,先判断当前上下文里是否已有该资源的节点,有就直接复用,没有就新建并缓存,同时把该节点挂到父节点上。这里的父节点可能是EntranceNode(入口节点),也可能是调用链上层的另一个资源的DefaultNode。多次调用同一个资源,并不会每次都新建Node,这也避免了一秒钟几万个请求各自开辟统计对象造成的内存膨胀。

4.2 ClusterBuilderSlot:把同一资源的流量汇聚起来

DefaultNode是按调用链维度划分的,同一个资源从A接口调进来和从B接口调进来,会形成两个不同DefaultNode,统计结果也就分开了。可限流很多时候只关心“这个资源总体扛了多少流量”,比如数据库访问这个方法不管是谁调的,总量到了就得拦。ClusterBuilderSlot就是干这个聚合活的。

它在第一个请求到来时,会为资源创建一个全局共享的ClusterNode,然后让该资源的所有DefaultNode都持有同一个ClusterNode引用。DefaultNode负责记录每一条调用链的细分指标,ClusterNode负责记录这个资源的总指标。看源码时你可以发现,ClusterNode的内部结构与DefaultNode几乎一样,但它没有再指向父节点,它就是一个服务全局资源的聚合统计点。这个设计很符合统计需求:查链路上某个上游业务对这个资源的调用量,看对应DefaultNode;查这个资源整体水位,看ClusterNode。

4.3 StatisticSlot:数据从这里开始“进账”

StatisticSlot本身不决策,它只负责记账。在责任链调用entry阶段,它会调用Node的increaseThreadNum()方法增加当前占用线程数,并调用addPassRequest()把这一次通过请求的计数累加到滑动窗口里。如果后面某个Slot执行失败,触发了BlockException,StatisticSlot会在BlockException被抛出前把block计数加上,保证“被拒绝的请求”也被统计,方便你观察拦截率和限流是否生效。

当业务代码执行完,责任链的exit阶段会调用它的decreaseThreadNum()和addRtAndSuccess(),把本次耗时计入RT总和,把成功次数加一。这里有个实际提醒:如果你在代码里调了SphU.entry()却忘了在finally里调exit(),线程占用数就会一直涨,哪怕请求早结束了,FlowSlot里的并发线程数规则也会误判,以为资源一直被占着,最终触发误限流。类似的坑排查起来特别隐蔽,后面常见问题里我再单独说。

4.4 FlowSlot:限流规则真正落地的位置

FlowSlot是很多人读源码最关心的部分。它通过FlowRuleChecker来校验当前资源是否超过配置的流量阈值。校验过程分两步:先拿到当前Context对应的统计Node,然后逐条遍历该资源的所有FlowRule,调用每个规则对应控制器的canPass方法。

FlowRule里有个核心字段grade,限流维度QPS还是并发线程数就由它决定。如果是QPS维度,检查时一秒钟窗口内已经通过的请求数是否超过阈值;如果是线程数维度,检查的是当前正在执行该资源的线程数是否超过阈值。这两个维度差别很大:QPS限制的是请求速度,适合接口防刷;线程数限制的是并发占用,适合保护数据库连接这类有限资源。

控制器的选择由controlBehavior字段决定。快速失败走DefaultController,逻辑最简单:当前计数超过阈值就返回false;Warm Up走WarmUpController,里面维护了一个带预热的计数读法,起始阶段实际阈值会低于配置阈值,随着时间推移慢慢抬高;排队等待走RateLimiterController,它会计算每个请求应该分配的时间间隙,如果排队等待时间超过配置超时则拒绝,否则请求会被sleep到指定时刻再放行。源码里还能看到WarmUpRateLimiterController,把预热和匀速排队组合到一起,适合既要冷启动保护又要削峰填谷的场景。

5. 一条请求从进入到限定的完整链路

5.1 入口:SphU.entry()发生了什么

日常代码里你写的是SphU.entry("hello", EntryType.IN)。这行代码的底层调用链比你想象的长。它最终会进入CtSph的entryWithType方法,这个方法会做几件事:检查当前线程的Context是否存在,没有就用ContextUtil.enter()创建一个;然后把资源、Context、EntryType等信息组装好,交给一个ProcessorSlotChain;最后调用chain的entry方法。

这里有个容易忽略的优化点:如果当前线程已经存在一个Context,并且链路里已经有这个资源的Entry,Sentinel会直接复用,不会重复创建新的Entry。这样设计是为了避免在一个递归方法或一次请求的多个嵌套资源里反复构造对象。

真正的入口校验动作是chain.entry(context, resourceWrapper, node, count, prioritized, args)。这个chain就是前面说的Slot责任链,第一个Slot开始执行时,传进去的参数会被一层层沿用,直到最后一个Slot执行完或遇到BlockException中断。

5.2 责任链从fireEntry开始传递

每个Slot都会实现entry方法,然后调用fireEntry让调用向下走。AbstractLinkedProcessorSlot的fireEntry方法本质上是执行next节点的entry方法。你可以在调试时看到整个栈非常清晰:Controller -> NodeSelectorSlot -> ClusterBuilderSlot -> LogSlot -> StatisticSlot -> AuthoritySlot -> SystemSlot -> FlowSlot -> DegradeSlot。如果断点打在FlowSlot,你会先看到前面所有Slot只做了节点构建和数据统计,并没有任何拦截决定,真正“拦不拦”是从FlowSlot开始才见分晓的。

exit阶段的传递是反过来的。每个Slot也实现exit方法,一层层往next传递,StatisticSlot在exit阶段把这次调用标记为成功并记录RT,从而完成一次完整统计闭环。

5.3 触发BlockException那一刻

当FlowRuleChecker发现当前QPS已经超过阈值,FlowSlot会抛出一个BlockException。这里的实现细节值得注意:FlowSlot在抛出前会调用做一些记录操作,然后中断整条责任链,后续Slot不再执行。对上层业务来说,你在entry调用处会捕获到这个异常,常见的处理方式是返回一个兜底结果、抛出业务友好提示,或者触发fallback逻辑。

源码里实际抛出的对象可能是FlowException、DegradeException、SystemBlockException或AuthorityException,都继承自BlockException。你可以用instanceof判断到底是哪类Block规则拦截的,这对排查线上问题非常有帮助——比如日志里看到一堆AuthorityException,你就该去查黑白名单配置,而不是在限流阈值上瞎调。

5.4 降级、系统保护与黑白名单都在哪一步

DegradeSlot位于FlowSlot之后,这意味着它先看流量是否达标,再看是否需要熔断。熔断的核心是一个状态机:Closed、Open、HalfOpen。Closed状态下正常统计,当慢调用比例、异常比例或异常数达到阈值,断路器切换到Open,之后一段时间内请求直接拒绝;时间窗口结束后进入HalfOpen,放一个探测请求进去,看资源是否恢复,成功则回到Closed,失败则重新Open。这套状态机在源码里的实现类是CircuitBreaker,每个资源对应一个breaker实例。

SystemSlot是另一个容易被忽略的保护层。它不是按资源维度限流,而是按整个系统的全局指标保护,包括系统Load、CPU使用率、平均RT和线程数。当整机负载很高时,即使单个资源的QPS没超,SystemSlot也可能直接拦截。特殊情况下,比如系统Load超过阈值,它会把入口流量控制在系统能承受的范围,这种“保护全局”的思路在生产环境关键时刻非常管用。

AuthoritySlot放在SystemSlot之前,做的是来源黑白名单校验。比如限制某些来源应用不能调用这个资源,或者只允许白名单内的来源调用。这四个Slot合在一起,覆盖了流量控制、熔断降级、全局保护、来源管控四类最常见需求。

6. 常见问题与排查技巧实录

6.1 限流不生效的几种典型原因

我在实际使用和给朋友排障时,遇到最多的限流不生效场景无非这几类。

第一,规则压根没加载成功。Sentinel的规则来源可以是代码硬编码、文件、Nacos等配置中心。排查时先输一下当前的规则列表,看看FlowRule有没有真正注册进去。很多时候是控制台推了一条规则,但应用没收到推送,或者应用启动时加载失败,日志被吞掉了。

第二,入口方式用错了。SphU和SphO是两个不同的入口,SphU.entry成功会返回Entry并抛出BlockException,SphO.entry判断布尔值。如果你规则维度统计的是QPS,但部分代码用SphU、部分用SphO,两边统计到的Node虽然可能是同一个,但使用习惯不一致时容易造成“看起来没生效”的错觉。

第三,异常没有上报。熔断降级的异常比例规则依赖业务抛出的异常被Tracer统计到。如果业务代码自己catch了异常没抛出来,也没有调用Tracer.trace,那Sentinel根本不知道这次调用是失败的,异常比例就永远是0。

第四,忘了在finally里调exit,导致并发线程数虚高,资源被误判为持续占用。这种问题通常表现为“配置100并发,实际10个请求就开始报Block”。遇到不顺,优先检查entry和exit是不是成对出现。

第五,规则阈值类型和预期不一致。比如你给数据库访问配了100 QPS限流,心里以为限的是每秒请求数,实际端口里写的是并发线程数维度,线程占用模型不同,表现就会大相径庭。

6.2 热点参数限流的使用与坑

Sentinel还支持热点参数限流,也就是对同一个资源的不同参数值单独限定。比如“查询商品”这个接口,操作某个爆款商品可以多给点额度,其他普通商品少给点额度。热点参数规则的判定不在FlowSlot里,而是走独立的ParamFlowSlot和ParamFlowRuleChecker,这也再次体现责任链扩展模式的威力——加一种规则类型,就是加一个Slot的事。

用热点参数时有两个坑很容易踩:一是参数索引必须和代码里传参位置对得上,从0开始计数,配错了规则完全不会触发;二是热点参数统计有一个容量上限,参数值非常多时,LRU淘汰可能把你要限的那个热点值给淘汰掉,导致限流失效。遇到这种场景,需要调大统计容量或者把“最高优先级”参数明确配置为MAX等特殊值来兜底。

6.3 信号量隔离与线程池隔离怎么选

很多人会问Sentinel能不能实现像线程池隔离那样的强隔离。严格来说,Sentinel默认的线程数限流是信号量隔离:只限制同时执行的线程数量,不限制线程切换。它适合大部分保护数据库连接、缓存连接池和第三方RPC的场景,因为不需要来回切换线程,性能开销很小。

如果你需要“慢调用不能占用线程”的强隔离效果,比如某个下游服务经常慢到几十秒,信号量隔离挡不住线程一直被占用造成的线程池耗尽,那就得考虑线程池隔离方案。Sentinel虽然没有原生线程池隔离,但官方文档也给了思路:把要隔离的调用单独放到一个线程池里,线程池的任务提交用SphU.entry保护;线程池线程数本身就充当了隔离边界。选型没有绝对对错,核心是搞清楚你要的是“限制QPS”还是“限制线程被占用的总量”,两者对应到资源和场景差别很大。

7. 最后分享几个调源码的技巧

读Sentinel源码,我建议不要从头到尾平面地读,那样容易在Slot链和统计类之间迷路。我常用的方式是:先只调试一个最简单的FlowRule限流场景,在SphU.entry调用处打一个断点,然后单步进入CtSph,你会看到Context、Node、责任链是怎么被组织起来的。接着在StatisticSlot和FlowSlot各打一个断点,观察两次断点之间计数变化,几乎一瞬间就能把“统计”和“判定”这两个阶段在脑子里分开。

修改责任链顺序的验证方法也很有用。我经常临时写一个自定义Slot插到FlowSlot前面,打印每个slot的执行时间,就能清楚知道哪个slot耗了多久。扩展自定义Slot时别忘了走SPI机制替换或增强SlotChainBuilder,直接在构建链时把自己实现的Slot插入指定位置即可,别在源码里硬改默认链条,否则升级版本会很难受。

如果你也在做中间件选型或者正在阅读类似基于责任链的框架,记住一点:最重要不是记住某个类的名字,而是搞清楚“谁先执行、谁负责统计、谁负责决策、中断条件是什么”。Sentinel这套骨架之所以能承载这么多规则类型还保持清晰,靠的就是责任链模式和Slot的单一职责。把这条主线抓住了,你不仅能更快读懂它的源码,未来自己设计可扩展系统时也多了一个可靠的参考模板。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询