线上服务最怕什么不是代码写得烂而是流量突然失控数据库连接被打满、缓存超时、线程池拒绝最后全链路像多米诺骨牌一样倒下。限流算法我接触过不少计数器、滑动窗口、令牌桶、漏桶各有各的适用场景而要把这些算法塞进一个生产可用的组件里还得有一套灵活的扩展骨架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总和。判断是否超限时不是只取当前这一格的数据而是把“从当前格往前推整个周期长度”的所有格子数据加起来。以intervalInMs1000毫秒、sampleCount10为例每一格长度就是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 为什么需要责任链而不是一堆ifSentinel要处理的事不少统计指标、黑白名单、系统保护、限流、熔断降级、异常日志。如果全部写在一个类里随着规则类型增加这个类很快就会变成几百上千行的“上帝类”每加一种能力都要改动既有代码风险非常高。它选择的是责任链模式把每个独立能力封装成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负责构建调用树为当前资源和调用链创建DefaultNodeClusterBuilderSlot负责为同一个资源聚合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执行失败触发了BlockExceptionStatisticSlot会在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逻辑最简单当前计数超过阈值就返回falseWarm 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并且链路里已经有这个资源的EntrySentinel会直接复用不会重复创建新的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并抛出BlockExceptionSphO.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的单一职责。把这条主线抓住了你不仅能更快读懂它的源码未来自己设计可扩展系统时也多了一个可靠的参考模板。