Sentinel 规则与隔离边界:资源、Slot 链与五类规则
先消歧一个同名概念:本页的 Sentinel 指 Alibaba Sentinel(阿里开源的流量治理组件,com.alibaba.csp 工件组),与 Redis Sentinel 哨兵(故障转移与 quorum 选举)没有任何代码或机制关系。Redis 侧的哨兵机制见 Redis 持久化、高可用与集群 的 Sentinel:quorum 与多数授权不是一件事 一节,两篇互不回指细节。
一个 Java 服务被流量打垮通常不是“没有限流”这一个原因:可能规则没生效、可能资源名写错、可能统计窗口看不到异常、可能熔断打开后永远回不去。Sentinel 把这些失败面各自做成可观察的机制——资源与调用树决定“统计谁”,Slot 链决定“先检查还是先记账”,五类规则决定“按什么阈值拒绝”,状态机决定“拒绝之后怎么恢复”。本页按 20 个知识点拆开这四层,规则全部绑定 sentinel 1.8.9 固定源码,不依赖任何控制台或云服务。
本页对应 U37 / #74、PRD L07/E。通用四道防线(deadline、重试放大、熔断状态机、隔离舱与恢复顺序)的模型权威在超时、重试、熔断、限流与降级:那页明确写着“熔断/限流为方法模型,未绑定具体库源码”,本页是那句话的续篇——把通用模型绑定到 Sentinel 1.8.9 的源码实现,通用结论零重复。Resilience4j 的状态机、Bulkhead 与 Spring Cloud CircuitBreaker 工厂权威在远程客户端与容错组合:Feign 与 Resilience4j(U33),本页只写第二实现的差异行。网关侧 Redis 令牌桶与 fail-open 的机制权威在 Gateway 可靠性(U32),本页只承接它 182 行“网关前加一层本地限流(如 Sentinel)兜底”那句的机制解释。Dubbo 适配器归 U41、Nacos 服务端机制归 U40、Seata 归 U38,本页只登记适配关系。
实验入口见 Sentinel 实验说明:五类规则、Context 调用树、滑动窗口、文件数据源热更新与注解路径均有公开实验设计;每条观察行的通过与否以 labs/sentinel/evidence/u37-2026-10-28.json 为准,本页不提前声明 PASS。Nacos 数据源、Dashboard 控制台、生产 token server 与 SCG 5.x 网关适配器明确标推演。
版本、维护状态与 Cloud Alibaba 适配登记
查询日期为 2026-10-28。本页固定 Sentinel 1.8.9,tag 1.8.9(lightweight tag,无 v 前缀)指向完整 commit d75a6687b82711f8611ef91f6a56e993d24f57c5(2025-10-17,根 pom.xml 第 41 行 <revision>1.8.9</revision>,Constants.java 第 27 行 SENTINEL_VERSION = VersionUtil.getVersion("1.8.9"))。工程基线 JDK 21(Liberica)、Maven 3.9.9、Boot 4.0.9、Framework 7.0.10、Cloud 2025.1.4,与 U03 基线一致。全文 source-excerpt 的 tag 字段使用完整 commit,SHA-256 对整文件计算,代码为连续原文。
维护状态(同日查询,均可复核):
| 事实 | 值 | 含义 |
|---|---|---|
| 1.8 分支最新 patch | 1.8.10(2026-05-21,9 commits / 60 files) | 1.8.9 之后一年内只有这一个 patch |
| Cloud Alibaba BOM | 2025.1.0.0(2026-02-06)仍钉 sentinel.version=1.8.9(POM 第 52 行) | 生产选型跟 BOM,不跟最新 patch |
| 2.0 线 | 只有 2.0.0-alpha(2023-02-28 prerelease),无后续 | 不构成可用版本 |
| spring.io 目录 | 无此项目、无 OSS/商业支持窗口 | 版本与支持只能从 GitHub/BOM/wiki 取证 |
| GitHub wiki | 关键页停滞在 2019–2022(Flow-Control 2020-01-29、Circuit-Breaking 2020-08-21、集群流控 2019-12-02) | wiki 只作能力目录索引,正文一律以固定 tag 源码为准 |
| 仓库状态 | 未归档,最后 push 2026-05-27,年均约 1 个 patch | 低频维护、feature 收敛 |
Cloud Alibaba 适配关系独立登记——Sentinel 不是 Cloud 核心内置,这是 PRD L07/E 明确要求的表述:
- Sentinel 本体(
sentinel-core、sentinel-transport、sentinel-cluster、sentinel-extension、sentinel-adapter)由alibaba/Sentinel独立发布,groupId 为com.alibaba.csp;sentinel-core运行时零第三方依赖(pom 依赖全部 test scope),脱离 Spring 也能完整使用五类规则。 - Spring 适配由 Spring Cloud Alibaba(SCA)2025.1.0.0 提供,BOM 第 195–215 行管理四个 SCA 自有模块(groupId 均为
com.alibaba.cloud,与org.springframework.cloud的 Cloud 核心 BOM 无关):spring-cloud-starter-alibaba-sentinel、spring-cloud-alibaba-sentinel-datasource、spring-cloud-alibaba-sentinel-gateway、spring-cloud-circuitbreaker-sentinel;同文件第 76 行起用dependencyManagement管理全部com.alibaba.csp:sentinel-*工件(core、parameter-flow-control、datasource-、transport-simple-http、annotation-aspectj、webmvc-v6x、gateway-v6x、cluster- 等)。 org.springframework.cloud的spring-cloud-dependenciesBOM 全文没有 sentinel 条目:starter、数据源、网关集成都归 Alibaba 发布,不能写成“Cloud 核心内置”。Spring Cloud CircuitBreaker(SCB)抽象下,Sentinel 是 R4j 之外的第三 provider(U33 已讲 SCB 工厂机制,本页只补差异)。- 适配器版本错配必须登记:
sentinel-spring-cloud-gateway-v6x-adapter的 pom 钉 SCG 4.3.0 / Spring 6.2.7(21、23 行),sentinel-spring-webmvc-v6x-adapter钉 Spring 6.0.2 / Boot 3.0.0(19、20 行);U03 基线是 Framework 7.0.10/SCG 5.0.4。1.8.9 的官方测试矩阵不覆盖 Framework 7,原子 API(SphU.entry)路径不受影响,WebMvc/网关适配器组合只能标注“未在官方支持矩阵内”。
资源、Entry 与 Context 调用树
Sentinel 的一切统计和规则都以**资源(resource)**为单位。资源是字符串名字(也可以是 Method),物理上不存在,语义上由一次 entry/exit 包围块定义。SphU 是编程式入口,CtSph 是其实现(Env.sph):
/**
* <p>The fundamental Sentinel API for recording statistics and performing rule checking for resources.</p>
* <p>
* Conceptually, physical or logical resource that need protection should be
* surrounded by an entry. The requests to this resource will be blocked if any
* criteria is met, eg. when any {@link Rule}'s threshold is exceeded. Once blocked,
* a {@link BlockException} will be thrown.
* </p>
* <p>
* To configure the criteria, we can use <code>XxxRuleManager.loadRules()</code> to load rules.
* </p>
*
* <p>
* Following code is an example, {@code "abc"} represent a unique name for the
* protected resource:
* </p>
*
* <pre>
* public void foo() {
* Entry entry = null;
* try {
* entry = SphU.entry("abc");
* // resource that need protection
* } catch (BlockException blockException) {
* // when goes there, it is blocked
* // add blocked handle logic here
* } catch (Throwable bizException) {
* // business exception
* Tracer.trace(bizException);
* } finally {
* // ensure finally be executed
* if (entry != null){
* entry.exit();
* }
* }
* }
* </pre>
*
* <p>
* Make sure {@code SphU.entry()} and {@link Entry#exit()} be paired in the same thread,
* otherwise {@link ErrorEntryFreeException} will be thrown.
* </p>
*
* @author jialiang.linjl
* @author Eric Zhao
* @see SphO
*/
public class SphU {
private static final Object[] OBJECTS0 = new Object[0];
private SphU() {}
/**
* Record statistics and perform rule checking for the given resource.
*
* @param name the unique name of the protected resource
* @return the {@link Entry} of this invocation (used for mark the invocation complete and get context data)
* @throws BlockException if the block criteria is met (e.g. metric exceeded the threshold of any rules)
*/
public static Entry entry(String name) throws BlockException {
return Env.sph.entry(name, EntryType.OUT, 1, OBJECTS0);
}三点约定在这段 javadoc 里已经写死:①entry 与 exit 必须同线程配对,否则抛 ErrorEntryFreeException;②被拒时抛的是 BlockException 家族(FlowException/DegradeException/ParamFlowException/SystemBlockException/AuthorityException),不是业务异常——这一条对应通用页 61 行“拒绝不是下游失败”的源码依据;③默认的 SphU.entry(String) 传的是 EntryType.OUT,只有显式传 EntryType.IN 的入口才会进入全局入站统计与系统规则(后文 §系统自适应会用到)。注意 SphU 还有带 Object... args 的重载(SphU.java:206),热点参数规则依赖它把业务参数传进 Slot 链。
配对失败的精确行为:exit 时若当前 Context 的 curEntry 不是本 Entry,CtEntry.exitForContext 会先把整条调用栈清掉再抛错:
protected void exitForContext(Context context, int count, Object... args) throws ErrorEntryFreeException {
if (context != null) {
// Null context should exit without clean-up.
if (context instanceof NullContext) {
return;
}
if (context.getCurEntry() != this) {
String curEntryNameInContext = context.getCurEntry() == null ? null
: context.getCurEntry().getResourceWrapper().getName();
// Clean previous call stack.
CtEntry e = (CtEntry) context.getCurEntry();
while (e != null) {
e.exit(count, args);
e = (CtEntry) e.parent;
}
String errorMessage = String.format("The order of entry exit can't be paired with the order of entry"
+ ", current entry in context: <%s>, but expected: <%s>", curEntryNameInContext,
resourceWrapper.getName());
throw new ErrorEntryFreeException(errorMessage);
} else {
// Go through the onExit hook of all slots.
if (chain != null) {
chain.exit(context, resourceWrapper, count, args);
}
// Go through the existing terminate handlers (associated to this invocation).
callExitHandlersAndCleanUp(context);
// Restore the call stack.
context.setCurEntry(parent);
if (parent != null) {
((CtEntry) parent).child = null;
}
if (parent == null) {
// Default context (auto entered) will be exited automatically.
if (ContextUtil.isDefaultContext(context)) {
ContextUtil.exit();
}
}
// Clean the reference of context in current entry to avoid duplicate exit.
clearEntryContext();
}
}
}“先清栈再抛错”意味着配对错误发生后统计已被部分清理,进程不会自动恢复健康——ErrorEntryFreeException 是设计上“不该发生”的编程错误,捕获后继续跑等于在损坏的调用栈上作判断。正确写法永远是 try/finally 且 exit 只在 entry != null 时调用一次。
Context 是调用树的根。ContextUtil.enter(name, origin) 把 Context 绑到当前线程(ThreadLocal),并懒创建每个 context 名全局共享的 EntranceNode;同名 context 共享同一个入口节点,最多允许 MAX_CONTEXT_NAME_SIZE=2000 个不同名 context,超限后进入 NullContext——不做任何规则检查:
protected static Context trueEnter(String name, String origin) {
Context context = contextHolder.get();
if (context == null) {
Map<String, DefaultNode> localCacheNameMap = contextNameNodeMap;
DefaultNode node = localCacheNameMap.get(name);
if (node == null) {
if (localCacheNameMap.size() > Constants.MAX_CONTEXT_NAME_SIZE) {
setNullContext();
return NULL_CONTEXT;
} else {
LOCK.lock();
try {
node = contextNameNodeMap.get(name);
if (node == null) {
if (contextNameNodeMap.size() > Constants.MAX_CONTEXT_NAME_SIZE) {
setNullContext();
return NULL_CONTEXT;
} else {
node = new EntranceNode(new StringResourceWrapper(name, EntryType.IN), null);
// Add entrance node.
Constants.ROOT.addChild(node);
Map<String, DefaultNode> newMap = new HashMap<>(contextNameNodeMap.size() + 1);
newMap.putAll(contextNameNodeMap);
newMap.put(name, node);
contextNameNodeMap = newMap;
}
}
} finally {
LOCK.unlock();
}
}
}
context = new Context(node, name);
context.setOrigin(origin);
contextHolder.set(context);
}
return context;两个行为要点:①ContextUtil.enter("sentinel_default_context") 会直接抛 ContextNameDefineException(ContextUtil.java:112-117)——默认 context 名禁止自定义;不显式 enter 时 CtSph 会自动使用默认 context(internalEnter(Constants.CONTEXT_DEFAULT_NAME))。②origin 是调用方应用名,后面授权规则与 limitApp 流控都读它;它来自这里传入的第二个参数,而不是 HTTP header 自动解析——适配器(WebMvc 拦截器等)负责把请求方身份填进来。
四层 Node:同一资源在不同 context 各有自己的 DefaultNode(调用树按 context 分棵),但全局共享一个 ClusterNode(资源级总量),可选地每个 (资源 × origin) 再建一个 origin 统计节点。NodeSelectorSlot 建树:
@Spi(isSingleton = false, order = Constants.ORDER_NODE_SELECTOR_SLOT)
public class NodeSelectorSlot extends AbstractLinkedProcessorSlot<Object> {
/**
* {@link DefaultNode}s of the same resource in different context.
*/
private volatile Map<String, DefaultNode> map = new HashMap<String, DefaultNode>(10);
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, Object obj, int count, boolean prioritized, Object... args)
throws Throwable {
/*
* It's interesting that we use context name rather resource name as the map key.
*
* Remember that same resource({@link ResourceWrapper#equals(Object)}) will share
* the same {@link ProcessorSlotChain} globally, no matter in which context. So if
* code goes into {@link #entry(Context, ResourceWrapper, DefaultNode, int, Object...)},
* the resource name must be same but context name may not.
*
* If we use {@link com.alibaba.csp.sentinel.SphU#entry(String resource)} to
* enter same resource in different context, using context name as map key can
* distinguish the same resource. In this case, multiple {@link DefaultNode}s will be created
* of the same resource name, for every distinct context (different context name) each.
*
* Consider another question. One resource may have multiple {@link DefaultNode},
* so what is the fastest way to get total statistics of the same resource?
* The answer is all {@link DefaultNode}s with same resource name share one
* {@link ClusterNode}. See {@link ClusterBuilderSlot} for detail.
*/
DefaultNode node = map.get(context.getName());
if (node == null) {
synchronized (this) {
node = map.get(context.getName());
if (node == null) {
node = new DefaultNode(resourceWrapper, null);
HashMap<String, DefaultNode> cacheMap = new HashMap<String, DefaultNode>(map.size());
cacheMap.putAll(map);
cacheMap.put(context.getName(), node);
map = cacheMap;
// Build invocation tree
((DefaultNode) context.getLastNode()).addChild(node);
}
}
}
context.setCurNode(node);
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}ClusterBuilderSlot 紧接着补上资源级唯一 ClusterNode 与 origin 节点:
@Spi(isSingleton = false, order = Constants.ORDER_CLUSTER_BUILDER_SLOT)
public class ClusterBuilderSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
/**
* <p>
* Remember that same resource({@link ResourceWrapper#equals(Object)}) will share
* the same {@link ProcessorSlotChain} globally, no matter in which context. So if
* code goes into {@link #entry(Context, ResourceWrapper, DefaultNode, int, boolean, Object...)},
* the resource name must be same but context name may not.
* </p>
* <p>
* To get total statistics of the same resource in different context, same resource
* shares the same {@link ClusterNode} globally. All {@link ClusterNode}s are cached
* in this map.
* </p>
* <p>
* The longer the application runs, the more stable this mapping will
* become. so we don't concurrent map but a lock. as this lock only happens
* at the very beginning while concurrent map will hold the lock all the time.
* </p>
*/
private static volatile Map<ResourceWrapper, ClusterNode> clusterNodeMap = new HashMap<>();
private static final Object lock = new Object();
private volatile ClusterNode clusterNode = null;
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
boolean prioritized, Object... args)
throws Throwable {
if (clusterNode == null) {
synchronized (lock) {
if (clusterNode == null) {
// Create the cluster node.
clusterNode = new ClusterNode(resourceWrapper.getName(), resourceWrapper.getResourceType());
HashMap<ResourceWrapper, ClusterNode> newMap = new HashMap<>(Math.max(clusterNodeMap.size(), 16));
newMap.putAll(clusterNodeMap);
newMap.put(node.getId(), clusterNode);
clusterNodeMap = newMap;
}
}
}
node.setClusterNode(clusterNode);
/*
* if context origin is set, we should get or create a new {@link Node} of
* the specific origin.
*/
if (!"".equals(context.getOrigin())) {
Node originNode = node.getClusterNode().getOrCreateOriginNode(context.getOrigin());
context.getCurEntry().setOriginNode(originNode);
}
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}由此得到四层统计结构,对应四种规则可见的口径:
| 层 | 谁创建 | 统计范围 | 谁在读它 |
|---|---|---|---|
EntranceNode(context 入口) | ContextUtil.trueEnter | 一个 context 名的全部入口流量 | 调用树观测;Constants.ROOT 之下 |
DefaultNode(context × 资源) | NodeSelectorSlot | 本 context 内该资源的通过/拒绝/RT | 流控 DIRECT 的 origin 分支之外、链路策略 |
ClusterNode(资源全局) | ClusterBuilderSlot | 全部 context 汇总 | 流控 default、熔断、热点参数 |
OriginNode(资源 × origin) | ClusterBuilderSlot.getOrCreateOriginNode | 单个调用方应用 | 流控 limitApp=origin、other;授权规则 |
ContextUtil 的 javadoc(96–100 行)明确警示 origin 节点数量是 资源数 × origin 数,origin 种类多时内存会膨胀——origin 应是有限的应用名集合,不是用户 ID。这是对“把用户身份塞 origin”的源码级反驳:授权规则叫“黑白名单”,但名单维度是调用方应用,不是终端用户。
最小可验证用法(纯 JVM、无 Spring,公开实验 A1 覆盖):
ContextUtil.enter("ctx-a", "app-a");
Entry outer = SphU.entry("u37-tree"); // DefaultNode(ctx-a, u37-tree) 挂到 EntranceNode(ctx-a)
Entry inner = SphU.entry("u37-child"); // 作为 outer 的子节点挂到调用树
inner.exit();
outer.exit();
ContextUtil.exit();
// Constants.ROOT.getChild("ctx-a") 下可读到两层调用树;同一资源在 ctx-b 下另有一棵 DefaultNodeSlot 链与 SPI 顺序
entry 不是一次函数调用,而是一条在装配期排好序的 ProcessorSlot 链。每个资源全局共享一条链(CtSph.lookProcessChain 按 ResourceWrapper 缓存,double-check 加锁),链由 SPI 装配:sentinel-core 的 META-INF/services/com.alibaba.csp.sentinel.slotchain.ProcessorSlot 列九个默认 slot,DefaultSlotChainBuilder 按 @Spi(order) 排序拼装;sentinel-parameter-flow-control 通过自己的同名 services 文件追加 ParamFlowSlot(@Spi(order = -3000))。顺序常量在 Constants.java:76-84:
* Global statistic node for inbound traffic. Usually used for {@code SystemRule} checking.
*/
public final static ClusterNode ENTRY_NODE = new ClusterNode(TOTAL_IN_RESOURCE_NAME, ResourceTypeConstants.COMMON);
/**
* The global switch for Sentinel.
*/
public static volatile boolean ON = true;
/**
* Order of default processor slots
*/
public static final int ORDER_NODE_SELECTOR_SLOT = -10000;
public static final int ORDER_CLUSTER_BUILDER_SLOT = -9000;
public static final int ORDER_LOG_SLOT = -8000;
public static final int ORDER_STATISTIC_SLOT = -7000;
public static final int ORDER_AUTHORITY_SLOT = -6000;
public static final int ORDER_SYSTEM_SLOT = -5000;
public static final int ORDER_FLOW_SLOT = -2000;
public static final int ORDER_DEFAULT_CIRCUIT_BREAKER_SLOT = -1500;
public static final int ORDER_DEGRADE_SLOT = -1000;完整链条(按 entry 方向):
NodeSelector(-10000) → ClusterBuilder(-9000) → Log(-8000) → Statistic(-7000)
→ Authority(-6000) → System(-5000) → ParamFlow(-3000,扩展 SPI 追加)
→ Flow(-2000) → DefaultCircuitBreaker(-1500) → Degrade(-1000)这条顺序解释了四个常见困惑:①System 在 Flow 之前——系统规则先于资源级流控判断;②ParamFlow 在 Flow 之前——热点参数先拒,资源级 QPS 后算;③两个熔断 slot:DefaultCircuitBreakerSlot 只处理资源名为 * 的“默认规则”,资源配了显式熔断规则时它直接跳过(DefaultCircuitBreakerSlot.java:49-52 的 DegradeRuleManager.hasConfig 短路);④Degrade 排在最后,所以半开探测请求若被前面任何规则拒绝,会触发后文的 whenTerminate 回退(#1638)。
最反直觉的一点:StatisticSlot 先 fireEntry 后记账。 通过计数只在全部下游 block 判定都通过之后才累加,被拒的请求走 catch BlockException 记 block 计数,两者绝不混:
@Spi(order = Constants.ORDER_STATISTIC_SLOT)
public class StatisticSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
boolean prioritized, Object... args) throws Throwable {
try {
// Do some checking.
fireEntry(context, resourceWrapper, node, count, prioritized, args);
// Request passed, add thread count and pass count.
node.increaseThreadNum();
node.addPassRequest(count);
if (context.getCurEntry().getOriginNode() != null) {
// Add count for origin node.
context.getCurEntry().getOriginNode().increaseThreadNum();
context.getCurEntry().getOriginNode().addPassRequest(count);
}
if (resourceWrapper.getEntryType() == EntryType.IN) {
// Add count for global inbound entry node for global statistics.
Constants.ENTRY_NODE.increaseThreadNum();
Constants.ENTRY_NODE.addPassRequest(count);
}
// Handle pass event with registered entry callback handlers.
for (ProcessorSlotEntryCallback<DefaultNode> handler : StatisticSlotCallbackRegistry.getEntryCallbacks()) {
handler.onPass(context, resourceWrapper, node, count, args);
}
} catch (PriorityWaitException ex) {
node.increaseThreadNum();
if (context.getCurEntry().getOriginNode() != null) {
// Add count for origin node.
context.getCurEntry().getOriginNode().increaseThreadNum();
}
if (resourceWrapper.getEntryType() == EntryType.IN) {
// Add count for global inbound entry node for global statistics.
Constants.ENTRY_NODE.increaseThreadNum();
}
// Handle pass event with registered entry callback handlers.
for (ProcessorSlotEntryCallback<DefaultNode> handler : StatisticSlotCallbackRegistry.getEntryCallbacks()) {
handler.onPass(context, resourceWrapper, node, count, args);
}
} catch (BlockException e) {
// Blocked, set block exception to current entry.
context.getCurEntry().setBlockError(e);
// Add block count.
node.increaseBlockQps(count);
if (context.getCurEntry().getOriginNode() != null) {
context.getCurEntry().getOriginNode().increaseBlockQps(count);
}
if (resourceWrapper.getEntryType() == EntryType.IN) {
// Add count for global inbound entry node for global statistics.
Constants.ENTRY_NODE.increaseBlockQps(count);
}
// Handle block event with registered entry callback handlers.
for (ProcessorSlotEntryCallback<DefaultNode> handler : StatisticSlotCallbackRegistry.getEntryCallbacks()) {
handler.onBlocked(e, context, resourceWrapper, node, count, args);
}
throw e;
} catch (Throwable e) {
// Unexpected internal error, set error to current entry.
context.getCurEntry().setError(e);
throw e;
}
}“先检查后记账”的直接后果是:本请求的拒绝不会进入 pass 统计,但会进入 block 统计;后续请求看到的 passQps 只包含真正执行了的流量。这与“先统计再检查”的朴素实现有本质差别——后者会在阈值边缘自我放大(被拒流量继续抬高 QPS,导致更多拒绝)。PriorityWaitException 是唯一的“半通过”分支:优先级请求等待后仍按通过记账(见 §流控 QPS)。
超限静默放行是“规则不保证命中”的第一条源码依据,两处上限都在装配与入口路径上:
ProcessorSlot<Object> lookProcessChain(ResourceWrapper resourceWrapper) {
ProcessorSlotChain chain = chainMap.get(resourceWrapper);
if (chain == null) {
synchronized (LOCK) {
chain = chainMap.get(resourceWrapper);
if (chain == null) {
// Entry size limit.
if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) {
return null;
}
chain = SlotChainProvider.newSlotChain();
Map<ResourceWrapper, ProcessorSlotChain> newMap = new HashMap<ResourceWrapper, ProcessorSlotChain>(
chainMap.size() + 1);
newMap.putAll(chainMap);
newMap.put(resourceWrapper, chain);
chainMap = newMap;
}
}
}
return chain;
} private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args)
throws BlockException {
Context context = ContextUtil.getContext();
if (context instanceof NullContext) {
// The {@link NullContext} indicates that the amount of context has exceeded the threshold,
// so here init the entry only. No rule checking will be done.
return new CtEntry(resourceWrapper, null, context);
}
if (context == null) {
// Using default context.
context = InternalContextUtil.internalEnter(Constants.CONTEXT_DEFAULT_NAME);
}
// Global switch is close, no rule checking will do.
if (!Constants.ON) {
return new CtEntry(resourceWrapper, null, context);
}
ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);
/*
* Means amount of resources (slot chain) exceeds {@link Constants.MAX_SLOT_CHAIN_SIZE},
* so no rule checking will be done.
*/
if (chain == null) {
return new CtEntry(resourceWrapper, null, context);
}
Entry e = new CtEntry(resourceWrapper, chain, context, count, args);
try {
chain.entry(context, resourceWrapper, null, count, prioritized, args);
} catch (BlockException e1) {
e.exit(count, args);
throw e1;
} catch (Throwable e1) {
// This should not happen, unless there are errors existing in Sentinel internal.
RecordLog.info("Sentinel unexpected exception", e1);
}
return e;
}entryWithPriority 里有三条静默放行路径,全部返回“没有 slot 链的 Entry”(chain == null),即不做任何规则检查:①Context 是 NullContext(context 名超 2000);②全局开关 Constants.ON == false(SphU 之外还可经命令通道关总闸);③本资源拿不到 slot 链(资源数超 MAX_SLOT_CHAIN_SIZE=6000,Constants.java:37)。注意第三条的判定顺序:只有新资源首次 entry 时才可能撞上限,已缓存的资源链不受影响;撞限后 RecordLog.info 只记日志,调用方完全无感。生产上这是“为什么规则对某些新资源完全不生效”的排查第一名:资源名来自动态拼接(URL、订单号、用户输入)时会无限膨胀。
其余 slot 的职责一句话:LogSlot 只写 RecordLog;AuthoritySlot 查黑白名单(§授权);SystemSlot 只对 EntryType.IN 生效(§系统);FlowSlot 遍历本资源全部 FlowRule,任一不通过即抛 FlowException(FlowSlot.java:160-170);DegradeSlot 在 entry 做 tryPass、在 exit 对“已通过请求”调用 onRequestComplete(§熔断)。
滑动窗口统计与异常记账
每个 Node 的计数器是秒级滑动窗口(LeapArray),默认 2 个桶、每桶 500ms、总窗口 1000ms;分钟级窗口(rollingCounterInMinute,60 桶 × 1s)用于 previousPassQps 等慢指标:
public abstract class LeapArray<T> {
protected int windowLengthInMs;
protected int sampleCount;
protected int intervalInMs;
private double intervalInSecond;
protected final AtomicReferenceArray<WindowWrap<T>> array;
/**
* The conditional (predicate) update lock is used only when current bucket is deprecated.
*/
private final ReentrantLock updateLock = new ReentrantLock();
/**
* The total bucket count is: {@code sampleCount = intervalInMs / windowLengthInMs}.
*
* @param sampleCount bucket count of the sliding window
* @param intervalInMs the total time interval of this {@link LeapArray} in milliseconds
*/
public LeapArray(int sampleCount, int intervalInMs) {
AssertUtil.isTrue(sampleCount > 0, "bucket count is invalid: " + sampleCount);
AssertUtil.isTrue(intervalInMs > 0, "total time interval of the sliding window should be positive");
AssertUtil.isTrue(intervalInMs % sampleCount == 0, "time span needs to be evenly divided");
this.windowLengthInMs = intervalInMs / sampleCount;
this.intervalInMs = intervalInMs;
this.intervalInSecond = intervalInMs / 1000.0;
this.sampleCount = sampleCount;
this.array = new AtomicReferenceArray<>(sampleCount);
}默认值来自 SampleCountProperty.SAMPLE_COUNT = 2 与 IntervalProperty.INTERVAL = RuleConstant.DEFAULT_WINDOW_INTERVAL_MS = 1000;StatisticNode 的第二个窗口是 new ArrayMetric(60, 60 * 1000, false)(StatisticNode.java:96-103)。桶按时间对齐滚动,WindowWrap 用时间戳判断当前桶是否过期,过期时加锁重置——所以窗口统计是“近似 1 秒滑动”,不是严格逐毫秒精确;LeapArray 的注释也承认并发下 currentPass 读数可能偏小(见 §流控 QPS 的借用逻辑)。
一个 Node 上有四个基础计数:pass、block、exception、rt(总 RT 与成功数)。前三者的记账位置分别是:pass 在 StatisticSlot 的 fireEntry 之后;block 在 catch BlockException;exception 在 exit 时的 recordCompleteFor——且显式排除 BlockException:
原码节选(同一固定 commit,StatisticSlot.java:132-167,“上一段摘录的下半部分”):
if (context.getCurEntry().getBlockError() == null) {
// Calculate response time (use completeStatTime as the time of completion).
long completeStatTime = TimeUtil.currentTimeMillis();
context.getCurEntry().setCompleteTimestamp(completeStatTime);
long rt = completeStatTime - context.getCurEntry().getCreateTimestamp();
Throwable error = context.getCurEntry().getError();
// Record response time and success count.
recordCompleteFor(node, count, rt, error);
recordCompleteFor(context.getCurEntry().getOriginNode(), count, rt, error);
if (resourceWrapper.getEntryType() == EntryType.IN) {
recordCompleteFor(Constants.ENTRY_NODE, count, rt, error);
}
}
// Handle exit event with registered exit callback handlers.
Collection<ProcessorSlotExitCallback> exitCallbacks = StatisticSlotCallbackRegistry.getExitCallbacks();
for (ProcessorSlotExitCallback handler : exitCallbacks) {
handler.onExit(context, resourceWrapper, count, args);
}
// fix bug https://github.com/alibaba/Sentinel/issues/2374
fireExit(context, resourceWrapper, count, args);
}
private void recordCompleteFor(Node node, int batchCount, long rt, Throwable error) {
if (node == null) {
return;
}
node.addRtAndSuccess(rt, batchCount);
node.decreaseThreadNum();
if (error != null && !(error instanceof BlockException)) {
node.increaseExceptionQps(batchCount);
}
}entry.getError() 什么时候会有值?只有两条路:①业务代码显式调用 Tracer.trace(e)(或 traceEntry/traceContext);②内部未预期异常被 StatisticSlot 的 catch (Throwable) 记入(“本不该发生”分支)。Tracer.shouldTrace 明确排除 BlockException(Tracer.java:201-205:“if (t == null || t instanceof BlockException) return false;”)——保护性拒绝永远不会被记成业务异常,这是“拒绝不是下游失败”在计数器层面的实现。由此得到一条经典的负控:异常抛了但没 trace,异常数恒为 0,异常比例熔断永远不打开。配套适配器(WebMvc 拦截器、Dubbo Filter 等)会在边界处替你把异常交给 Tracer;纯 API 编程时忘了 trace 是最高频的“规则不生效”之一。公开实验 A3 就是这条负控:同一段抛异常的代码,不 trace 时 exception 计数不动,trace 后计数出现。
观测读法是分层而不是一个数:passQps()/blockQps()/exceptionQps()/avgRt()/curThreadNum() 是本 Node 的秒级窗口读数;previousPassQps() 读分钟窗口的上一分钟通过量(WarmUp 用它做令牌恢复,见 §预热)。不要把 blockQps 当异常率、也不要用 avgRt 判断单次慢调用——熔断慢调用比例用的是“超过阈值的单次 RT 计数”,不是平均值(§熔断三策略)。
流控 QPS:快拒与 prioritized
流控规则 FlowRule 的三要素是 grade(阈值类型)、strategy(按调用关系选节点)、controlBehavior(超限行为),字段与默认值在固定源码里:
/**
* <p>
* Each flow rule is mainly composed of three factors: <strong>grade</strong>,
* <strong>strategy</strong> and <strong>controlBehavior</strong>:
* </p>
* <ul>
* <li>The {@link #grade} represents the threshold type of flow control (by QPS or thread count).</li>
* <li>The {@link #strategy} represents the strategy based on invocation relation.</li>
* <li>The {@link #controlBehavior} represents the QPS shaping behavior (actions on incoming request when QPS
* exceeds the threshold).</li>
* </ul>
*
* @author jialiang.linjl
* @author Eric Zhao
*/
public class FlowRule extends AbstractRule {
public FlowRule() {
super();
setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
}
public FlowRule(String resourceName) {
super();
setResource(resourceName);
setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
}
/**
* The threshold type of flow control (0: thread count, 1: QPS).
*/
private int grade = RuleConstant.FLOW_GRADE_QPS;
/**
* Flow control threshold count.
*/
private double count;
/**
* Flow control strategy based on invocation chain.
*
* {@link RuleConstant#STRATEGY_DIRECT} for direct flow control (by origin);
* {@link RuleConstant#STRATEGY_RELATE} for relevant flow control (with relevant resource);
* {@link RuleConstant#STRATEGY_CHAIN} for chain flow control (by entrance resource).
*/
private int strategy = RuleConstant.STRATEGY_DIRECT;
/**
* Reference resource in flow control with relevant resource or context.
*/
private String refResource;
/**
* Rate limiter control behavior.
* 0. default(reject directly), 1. warm up, 2. rate limiter, 3. warm up + rate limiter
*/
private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT;
private int warmUpPeriodSec = 10;
/**
* Max queueing time in rate limiter behavior.
*/
private int maxQueueingTimeMs = 500;
private boolean clusterMode;
/**
* Flow rule config for cluster mode.
*/
private ClusterFlowConfig clusterConfig;
/**
* The traffic shaping (throttling) controller.
*/
private TrafficShapingController controller;常量全集(grade/strategy/controlBehavior/authority/degrade 的编码与默认样本数)在 RuleConstant:
public final class RuleConstant {
public static final int FLOW_GRADE_THREAD = 0;
public static final int FLOW_GRADE_QPS = 1;
public static final int DEGRADE_GRADE_RT = 0;
/**
* Degrade by biz exception ratio in the current {@link IntervalProperty#INTERVAL} second(s).
*/
public static final int DEGRADE_GRADE_EXCEPTION_RATIO = 1;
/**
* Degrade by biz exception count in the last 60 seconds.
*/
public static final int DEGRADE_GRADE_EXCEPTION_COUNT = 2;
public static final int DEGRADE_DEFAULT_SLOW_REQUEST_AMOUNT = 5;
public static final int DEGRADE_DEFAULT_MIN_REQUEST_AMOUNT = 5;
public static final int AUTHORITY_WHITE = 0;
public static final int AUTHORITY_BLACK = 1;
public static final int STRATEGY_DIRECT = 0;
public static final int STRATEGY_RELATE = 1;
public static final int STRATEGY_CHAIN = 2;
public static final int CONTROL_BEHAVIOR_DEFAULT = 0;
public static final int CONTROL_BEHAVIOR_WARM_UP = 1;
public static final int CONTROL_BEHAVIOR_RATE_LIMITER = 2;
public static final int CONTROL_BEHAVIOR_WARM_UP_RATE_LIMITER = 3;controlBehavior 到实现类的映射由 FlowRuleUtil.generateRater 完成;注意只有 QPS 档才看 controlBehavior,线程档无论配什么都固定回落到 DefaultController:
private static TrafficShapingController generateRater(/*@Valid*/ FlowRule rule) {
if (rule.getGrade() == RuleConstant.FLOW_GRADE_QPS) {
switch (rule.getControlBehavior()) {
case RuleConstant.CONTROL_BEHAVIOR_WARM_UP:
return new WarmUpController(rule.getCount(), rule.getWarmUpPeriodSec(),
ColdFactorProperty.coldFactor);
case RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER:
return new ThrottlingController(rule.getMaxQueueingTimeMs(), rule.getCount());
case RuleConstant.CONTROL_BEHAVIOR_WARM_UP_RATE_LIMITER:
return new WarmUpRateLimiterController(rule.getCount(), rule.getWarmUpPeriodSec(),
rule.getMaxQueueingTimeMs(), ColdFactorProperty.coldFactor);
case RuleConstant.CONTROL_BEHAVIOR_DEFAULT:
default:
// Default mode or unknown mode: default traffic shaping controller (fast-reject).
}
}
return new DefaultController(rule.getCount(), rule.getGrade());
}默认行为(CONTROL_BEHAVIOR_DEFAULT)是快速失败:超阈值立即拒绝。DefaultController 的判定是“当前读数 + acquireCount 是否超 count”,QPS 档用 passQps()、线程档用 curThreadNum();被拒的优先级请求(prioritized=true)会尝试借用未来窗口的额度:
public class DefaultController implements TrafficShapingController {
private static final int DEFAULT_AVG_USED_TOKENS = 0;
private double count;
private int grade;
public DefaultController(double count, int grade) {
this.count = count;
this.grade = grade;
}
@Override
public boolean canPass(Node node, int acquireCount) {
return canPass(node, acquireCount, false);
}
@Override
public boolean canPass(Node node, int acquireCount, boolean prioritized) {
int curCount = avgUsedTokens(node);
if (curCount + acquireCount > count) {
if (prioritized && grade == RuleConstant.FLOW_GRADE_QPS) {
long currentTime;
long waitInMs;
currentTime = TimeUtil.currentTimeMillis();
waitInMs = node.tryOccupyNext(currentTime, acquireCount, count);
if (waitInMs < OccupyTimeoutProperty.getOccupyTimeout()) {
node.addWaitingRequest(currentTime + waitInMs, acquireCount);
node.addOccupiedPass(acquireCount);
sleep(waitInMs);
// PriorityWaitException indicates that the request will pass after waiting for {@link @waitInMs}.
throw new PriorityWaitException(waitInMs);
}
}
return false;
}
return true;
}avgUsedTokens(同文件 71–77 行)一行区分两档:grade == FLOW_GRADE_THREAD ? node.curThreadNum() : (int)(node.passQps())。“借用未来额度”的具体算法在 StatisticNode.tryOccupyNext:从未来最近的窗口开始,逐个检查“借用后把被借窗口的 pass 计入是否仍不超 maxCount”,可行则返回需要等待的毫秒数:
public long tryOccupyNext(long currentTime, int acquireCount, double threshold) {
double maxCount = threshold * IntervalProperty.INTERVAL / 1000;
long currentBorrow = rollingCounterInSecond.waiting();
if (currentBorrow >= maxCount) {
return OccupyTimeoutProperty.getOccupyTimeout();
}
int windowLength = IntervalProperty.INTERVAL / SampleCountProperty.SAMPLE_COUNT;
long earliestTime = currentTime - currentTime % windowLength + windowLength - IntervalProperty.INTERVAL;
int idx = 0;
/*
* Note: here {@code currentPass} may be less than it really is NOW, because time difference
* since call rollingCounterInSecond.pass(). So in high concurrency, the following code may
* lead more tokens be borrowed.
*/
long currentPass = rollingCounterInSecond.pass();
while (earliestTime < currentTime) {
long waitInMs = idx * windowLength + windowLength - currentTime % windowLength;
if (waitInMs >= OccupyTimeoutProperty.getOccupyTimeout()) {
break;
}
long windowPass = rollingCounterInSecond.getWindowPass(earliestTime);
if (currentPass + currentBorrow + acquireCount - windowPass <= maxCount) {
return waitInMs;
}
earliestTime += windowLength;
currentPass -= windowPass;
idx++;
}
return OccupyTimeoutProperty.getOccupyTimeout();
}优先级借用只对 SphU.entryWithPriority(...)(SphU.java:262-278,固定 prioritized=true)生效;默认 SphU.entry 永远是快拒。等待上限默认 500ms(OccupyTimeoutProperty.java:40 occupyTimeout = 500),等不到就照常拒绝。这是“同一条规则的两个分支”,不是两条规则:prioritized 不是“排队模式”,只是给少数高优调用方一个不超过 500ms 的未来额度借用;普通调用方拿不到这个分支。常见误读有两个:把 acquireCount 默认 1 理解成“批量语义”(实际 SphU.entry(name, n) 才一次申请 n 个 token,n 个都算 pass);以为 loadRules 异步生效(FlowRuleManager.loadRules 是同步替换整个规则集,下一次 entry 立即生效)。公开实验 A4 的观察行 flowQps 用 count=2 连发 5 次,期望 2 过 3 块——具体数字以证据 JSON 为准。
并发线程流控与“隔离”
grade=THREAD 把阈值判定从 passQps() 换成 curThreadNum():进入 entry 时线程数 +1、exit 时 −1(recordCompleteFor 里的 decreaseThreadNum)。效果是限制同一资源同时在处理的请求数,超过即拒。它保护的是“慢依赖耗尽线程/连接”这一类故障,对应通用页 63–69 行的小定律推演(500 QPS × 80ms ≈ 40 在途;慢到 800ms 就在途 400),本页不重复推导。
关键边界:Sentinel 没有独立的 Bulkhead 舱壁模块,THREAD 流控不是舱壁。三处差异必须讲清:
- 没有排队语义。
DefaultController在 entry 时同步判定curThreadNum + acquireCount > count即返回 false,调用方立刻拿到FlowException;R4j 的Bulkhead(信号量或线程池)有maxWaitDuration等待窗口(U33 页Retry、Bulkhead、TimeLimiter:组合的顺序一节)。Sentinel 的 THREAD 流控对应“快速拒绝的并发上限”,不是“有界队列舱”。 - 没有舱壁分组。R4j 为每个依赖建独立
Bulkhead实例、线程池物理隔离;Sentinel 的“分组”只能靠资源名约定(不同下游用不同资源名),线程池仍是应用共享的——THREAD 规则只是阻止新请求进入,并不为被保护的资源预留执行资源。真正的线程池隔离需要自己按依赖拆池(Boot 线程池配置),Sentinel 不提供。 - 线程数记账依赖 exit。既然
curThreadNum在 exit 时递减,任何漏写exit的代码都会让计数器只增不减,最终资源被自己的历史泄漏永久锁死(表现为“没有流量但一直 FlowException”)。这也是ErrorEntryFreeException(§资源)之外,try/finally必须遵守的第二个理由。
// 线程=1 时:第一个线程阻塞在业务里,第二个线程立即 FlowException 而不是排队
FlowRule rule = new FlowRule("u37-thread-lab");
rule.setGrade(RuleConstant.FLOW_GRADE_THREAD);
rule.setCount(1);
FlowRuleManager.loadRules(List.of(rule));公开实验 A4 的 flowThread 观察行覆盖该场景(结果以证据为准)。与 QPS 对照:QPS 限“单位时间进入多少”,THREAD 限“同时在里面多少”;只配 QPS 不配 THREAD 时,慢下游会把在途数推高到连接池上限(U33 的“只设 QPS 不限并发会让慢依赖耗尽连接”同理)。术语纪律:本页不用“舱壁/隔离舱”指 Sentinel 的任何功能;“隔离”在本页只指“THREAD 流控对并发数的快速拒绝”,与 R4j Bulkhead 的语义差异见 §Sentinel 与 Resilience4j。
关联、链路与 limitApp
strategy 决定“规则检查读哪个节点”,limitApp 决定“这个规则只管哪个调用方”。二者组合的分派在 FlowRuleChecker.selectNodeByRequesterAndStrategy,这是流控里最容易配错的一段:
static Node selectReferenceNode(FlowRule rule, Context context, DefaultNode node) {
String refResource = rule.getRefResource();
int strategy = rule.getStrategy();
if (StringUtil.isEmpty(refResource)) {
return null;
}
if (strategy == RuleConstant.STRATEGY_RELATE) {
return ClusterBuilderSlot.getClusterNode(refResource);
}
if (strategy == RuleConstant.STRATEGY_CHAIN) {
if (!refResource.equals(context.getName())) {
return null;
}
return node;
}
// No node.
return null;
}
private static boolean filterOrigin(String origin) {
// Origin cannot be `default` or `other`.
return !RuleConstant.LIMIT_APP_DEFAULT.equals(origin) && !RuleConstant.LIMIT_APP_OTHER.equals(origin);
}
static Node selectNodeByRequesterAndStrategy(/*@NonNull*/ FlowRule rule, Context context, DefaultNode node) {
// The limit app should not be empty.
String limitApp = rule.getLimitApp();
int strategy = rule.getStrategy();
String origin = context.getOrigin();
if (limitApp.equals(origin) && filterOrigin(origin)) {
if (strategy == RuleConstant.STRATEGY_DIRECT) {
// Matches limit origin, return origin statistic node.
return context.getOriginNode();
}
return selectReferenceNode(rule, context, node);
} else if (RuleConstant.LIMIT_APP_DEFAULT.equals(limitApp)) {
if (strategy == RuleConstant.STRATEGY_DIRECT) {
// Return the cluster node.
return node.getClusterNode();
}
return selectReferenceNode(rule, context, node);
} else if (RuleConstant.LIMIT_APP_OTHER.equals(limitApp)
&& FlowRuleManager.isOtherOrigin(origin, rule.getResource())) {
if (strategy == RuleConstant.STRATEGY_DIRECT) {
return context.getOriginNode();
}
return selectReferenceNode(rule, context, node);
}
return null;
}把这段读成三张表:
| strategy | 读哪个节点 | 经典用途 | 反例 |
|---|---|---|---|
DIRECT(默认) | limitApp=default → 资源 ClusterNode;limitApp=origin 名 → 该 origin 节点;limitApp=other → 未单列 origin 的节点 | 资源级 QPS/线程上限;按调用方配额 | 以为 default 是“默认配额”而不是“全部调用方合计” |
RELATE | refResource 的 ClusterNode | 写接口限流看读接口流量:读写共享下游时,用读的 QPS 触发写的限制 | 以为 relate 是“保护关联资源”(实际是用关联资源当阈值来源,被限的是本规则资源) |
CHAIN | 仅当 context.getName() 等于 refResource 才返回本资源节点 | 只限制某个入口链路(如只限 web context 进入的调用) | 以为 chain 按资源名匹配(它按 context 名匹配,配错名会静默放行) |
“静默放行”是本节的第二个 K05 级反例:passLocalCheck 里 selectedNode == null 时直接 return true(FlowRuleChecker.java:78-86)。RELATE 的 refResource 没被任何流量创建过 ClusterNode、CHAIN 的 context 名不匹配、limitApp 指向的 origin 从未出现——全部是放行,没有任何报错。配置错误的效果是“规则似乎不存在”,排查时要先确认节点是否存在(ClusterBuilderSlot.getClusterNode(refResource) != null、context 名与你 ContextUtil.enter 的名字一致)。
limitApp 的第三个值 other 由 FlowRuleManager.isOtherOrigin(origin, resource) 定义:origin 非空,且本资源没有任何一条 limitApp 精确等于该 origin 的规则——即“未被单独点名”的调用方共享 other 配额。配套的 checkFlow 总入口还有一条兜底:limitApp == null 直接放行(FlowRuleChecker.java:67-69):
public class FlowRuleChecker {
public void checkFlow(Function<String, Collection<FlowRule>> ruleProvider, ResourceWrapper resource,
Context context, DefaultNode node, int count, boolean prioritized) throws BlockException {
if (ruleProvider == null || resource == null) {
return;
}
Collection<FlowRule> rules = ruleProvider.apply(resource.getName());
if (rules != null) {
for (FlowRule rule : rules) {
if (!canPassCheck(rule, context, node, count, prioritized)) {
throw new FlowException(rule.getLimitApp(), rule);
}
}
}
}
public boolean canPassCheck(/*@NonNull*/ FlowRule rule, Context context, DefaultNode node,
int acquireCount) {
return canPassCheck(rule, context, node, acquireCount, false);
}
public boolean canPassCheck(/*@NonNull*/ FlowRule rule, Context context, DefaultNode node, int acquireCount,
boolean prioritized) {
String limitApp = rule.getLimitApp();
if (limitApp == null) {
return true;
}
if (rule.isClusterMode()) {
return passClusterCheck(rule, context, node, acquireCount, prioritized);
}
return passLocalCheck(rule, context, node, acquireCount, prioritized);
}
private static boolean passLocalCheck(FlowRule rule, Context context, DefaultNode node, int acquireCount,
boolean prioritized) {
Node selectedNode = selectNodeByRequesterAndStrategy(rule, context, node);
if (selectedNode == null) {
return true;
}
return rule.getRater().canPass(selectedNode, acquireCount, prioritized);
}checkFlow 的遍历语义是“任一规则不通过即拒”:一个资源可以挂多条 FlowRule(不同 origin、不同 strategy),它们同时生效、取最严。FlowException 携带 rule.getLimitApp() 与规则本身,所以日志/降级分支能直接分辨“哪条规则拒的”。公开实验 A5 覆盖三件事:RELATE 让 B 资源流量触发 A 资源拒绝、CHAIN 在错误 context 下放行、origin 不匹配直接通过(结果以证据 flowQps/systemAuthority 相关行为准)。
Warm-Up 预热
Warm-Up 的目标是“冷启动的系统不要一次吃满阈值”:阈值从 count / coldFactor 附近爬升到 count,持续 warmUpPeriodSec 秒(默认 10)。实现是 Guava 冷启动令牌桶的变体,coldFactor 默认 3(SentinelConfig.DEFAULT_COLD_FACTOR = 3,且 coldFactor <= 1 会被配置层重置回默认;WarmUpController 构造器对 coldFactor <= 1 直接抛非法参数):
public class WarmUpController implements TrafficShapingController {
protected double count;
private int coldFactor;
protected int warningToken = 0;
private int maxToken;
protected double slope;
protected AtomicLong storedTokens = new AtomicLong(0);
protected AtomicLong lastFilledTime = new AtomicLong(0);
public WarmUpController(double count, int warmUpPeriodInSec, int coldFactor) {
construct(count, warmUpPeriodInSec, coldFactor);
}
public WarmUpController(double count, int warmUpPeriodInSec) {
construct(count, warmUpPeriodInSec, 3);
}
private void construct(double count, int warmUpPeriodInSec, int coldFactor) {
if (coldFactor <= 1) {
throw new IllegalArgumentException("Cold factor should be larger than 1");
}
this.count = count;
this.coldFactor = coldFactor;
// thresholdPermits = 0.5 * warmupPeriod / stableInterval.
// warningToken = 100;
warningToken = (int)(warmUpPeriodInSec * count) / (coldFactor - 1);
// / maxPermits = thresholdPermits + 2 * warmupPeriod /
// (stableInterval + coldInterval)
// maxToken = 200
maxToken = warningToken + (int)(2 * warmUpPeriodInSec * count / (1.0 + coldFactor));
// slope
// slope = (coldIntervalMicros - stableIntervalMicros) / (maxPermits
// - thresholdPermits);
slope = (coldFactor - 1.0) / count / (maxToken - warningToken);
}
@Override
public boolean canPass(Node node, int acquireCount) {
return canPass(node, acquireCount, false);
}
@Override
public boolean canPass(Node node, int acquireCount, boolean prioritized) {
long passQps = (long) node.passQps();
long previousQps = (long) node.previousPassQps();
syncToken(previousQps);
// 开始计算它的斜率
// 如果进入了警戒线,开始调整他的qps
long restToken = storedTokens.get();
if (restToken >= warningToken) {
long aboveToken = restToken - warningToken;
// 消耗的速度要比warning快,但是要比慢
// current interval = restToken*slope+1/count
double warningQps = Math.nextUp(1.0 / (aboveToken * slope + 1.0 / count));
if (passQps + acquireCount <= warningQps) {
return true;
}
} else {
if (passQps + acquireCount <= count) {
return true;
}
}
return false;
}把这段翻译成可观察行为:令牌充足(restToken >= warningToken,即系统“冷”)时,实际生效阈值是 warningQps = 1 / (aboveToken × slope + 1/count)——令牌越多,允许的 QPS 越低,最低逼近 count / coldFactor;令牌消耗到警戒线以下后按完整 count 放行。令牌补充在 syncToken/coolDownTokens(同文件 140–175 行):每秒按 count 恢复,但只有当前 passQps 低于 count / coldFactor 时才继续冷却恢复,否则令牌停在警戒区——这就是“只暖不烫”的机制:稳态高流量下预热不会无限制恢复令牌。
两个常见误读正是本条 K05 反例的来源:①把 coldFactor 当“倍数扩容”(它是冷启动的收缩比:初始阈值约为 count/3,不是放大 3 倍);②以为预热是固定速率的直线斜升(实际是令牌桶驱动的斜率渐变,且中途流量变化会改变恢复速度)。还有一条代码级注意:预热控制器读的是 previousPassQps()(分钟窗口上一分钟的通过量)来补令牌,改阈值/流量突变的第一个窗口内行为不精确。公开实验 A6 的 warmupThrottle 观察行以秒级时间驱动观察爬坡(具体通过序列以证据为准,不在正文预设数字)。
匀速排队:ThrottlingController
CONTROL_BEHAVIOR_RATE_LIMITER 是匀速排队(漏桶整形):允许的请求按固定间隔通过,超出的请求在 maxQueueingTimeMs(默认 500ms)内阻塞等待,等不到才拒。它平滑下游流量,代价是占用调用线程——与默认快拒形成“等待 vs 立即失败”的两种语义。1.8.9 的实现类名是 ThrottlingController(源码内注释注明“Refactored from legacy RateLimitController of Sentinel 1.x”,javadoc @since 2.0);1.8.9 中不存在 RateLimiterController/RateLimitController 类,检索旧文章按旧类名读源码会对不上:
public class ThrottlingController implements TrafficShapingController {
// Refactored from legacy RateLimitController of Sentinel 1.x.
private static final long MS_TO_NS_OFFSET = TimeUnit.MILLISECONDS.toNanos(1);
private final int maxQueueingTimeMs;
private final int statDurationMs;
private final double count;
private final boolean useNanoSeconds;
private final AtomicLong latestPassedTime = new AtomicLong(-1);
public ThrottlingController(int queueingTimeoutMs, double maxCountPerStat) {
this(queueingTimeoutMs, maxCountPerStat, 1000);
}
public ThrottlingController(int queueingTimeoutMs, double maxCountPerStat, int statDurationMs) {
AssertUtil.assertTrue(statDurationMs > 0, "statDurationMs should be positive");
AssertUtil.assertTrue(maxCountPerStat >= 0, "maxCountPerStat should be >= 0");
AssertUtil.assertTrue(queueingTimeoutMs >= 0, "queueingTimeoutMs should be >= 0");
this.maxQueueingTimeMs = queueingTimeoutMs;
this.count = maxCountPerStat;
this.statDurationMs = statDurationMs;
// Use nanoSeconds when durationMs%count != 0 or count/durationMs> 1 (to be accurate)
if (maxCountPerStat > 0) {
this.useNanoSeconds = statDurationMs % Math.round(maxCountPerStat) != 0 || maxCountPerStat / statDurationMs > 1;
} else {
this.useNanoSeconds = false;
}
}
@Override
public boolean canPass(Node node, int acquireCount) {
return canPass(node, acquireCount, false);
}
private boolean checkPassUsingNanoSeconds(int acquireCount, double maxCountPerStat) {
final long maxQueueingTimeNs = maxQueueingTimeMs * MS_TO_NS_OFFSET;
long currentTime = System.nanoTime();
// Calculate the interval between every two requests.
final long costTimeNs = Math.round(1.0d * MS_TO_NS_OFFSET * statDurationMs * acquireCount / maxCountPerStat);
// Expected pass time of this request.
long expectedTime = costTimeNs + latestPassedTime.get();
if (expectedTime <= currentTime) {
// Contention may exist here, but it's okay.
latestPassedTime.set(currentTime);
return true;
} else {
final long curNanos = System.nanoTime();
// Calculate the time to wait.
long waitTime = costTimeNs + latestPassedTime.get() - curNanos;
if (waitTime > maxQueueingTimeNs) {
return false;
}
long oldTime = latestPassedTime.addAndGet(costTimeNs);
waitTime = oldTime - curNanos;
if (waitTime > maxQueueingTimeNs) {
latestPassedTime.addAndGet(-costTimeNs);
return false;
}
// in race condition waitTime may <= 0
if (waitTime > 0) {
sleepNanos(waitTime);
}
return true;
}
}机制拆开看:①每个请求的“理论间隔” costTime = statDurationMs × acquireCount / count(count=10 时每 100ms 一个);②latestPassedTime 记录上一个被许可请求的时间戳,用 CAS 推进(addAndGet 与回退);③预计等待超过 maxQueueingTimeMs 直接拒;④否则 sleep 到理论时间点再放行。useNanoSeconds 在 1000 % count != 0 或 count > 1000 时选择纳秒精度路径(同文件还有毫秒路径 checkPassUsingMilliSeconds)。注意它不读写 Node 的 QPS 统计,纯时间戳整形——所以匀速排队与快拒在同一资源上可以叠加多条规则,但排队规则本身对 passQps 阈值不敏感。
反例两条:①把“QPS + 默认 behavior”当排队(默认是快拒,排队必须显式 CONTROL_BEHAVIOR_RATE_LIMITER);②以为排队不阻塞调用线程(sleep 发生在 canPass 里,即发生在 SphU.entry 内部,调用线程被真实占用;线程池小时排队会把 Tomcat 线程耗尽——这是“用排队替代扩容”的典型代价)。公开实验 A6 的 warmupThrottle 观察行同时覆盖排队间隔与超 maxQueueingTimeMs 拒绝(结果以证据为准)。
熔断三策略:慢调用、异常比例与异常数
熔断规则 DegradeRule 是单选一的三策略(不是像 R4j 那样失败率+慢调用率双阈值同时生效):grade=0 慢调用比例(RT 模式,count 是最大允许 RT 毫秒数)、grade=1 异常比例(count ∈ [0,1])、grade=2 异常数(count 是次数)。公共字段:timeWindow(秒)、minRequestAmount 默认 5、slowRatioThreshold 默认 1.0、statIntervalMs 默认 1000ms(自 1.8.0 起可配):
* access to this resource will be blocked.
* </li>
* <li>
* Exception ratio: When the ratio of exception count per second and the
* success qps exceeds the threshold, access to the resource will be blocked in
* the coming window.
* </li>
* </ul>
*
* @author jialiang.linjl
* @author Eric Zhao
*/
public class DegradeRule extends AbstractRule {
public DegradeRule() {}
public DegradeRule(String resourceName) {
setResource(resourceName);
}
/**
* Circuit breaking strategy (0: average RT, 1: exception ratio, 2: exception count).
*/
private int grade = RuleConstant.DEGRADE_GRADE_RT;
/**
* Threshold count. The exact meaning depends on the field of grade.
* <ul>
* <li>In average RT mode, it means the maximum response time(RT) in milliseconds.</li>
* <li>In exception ratio mode, it means exception ratio which between 0.0 and 1.0.</li>
* <li>In exception count mode, it means exception count</li>
* <ul/>
*/
private double count;
/**
* Recovery timeout (in seconds) when circuit breaker opens. After the timeout, the circuit breaker will
* transform to half-open state for trying a few requests.
*/
private int timeWindow;
/**
* Minimum number of requests (in an active statistic time span) that can trigger circuit breaking.
*
* @since 1.7.0
*/
private int minRequestAmount = RuleConstant.DEGRADE_DEFAULT_MIN_REQUEST_AMOUNT;
/**
* The threshold of slow request ratio in RT mode.
*
* @since 1.8.0
*/
private double slowRatioThreshold = 1.0d;
/**
* The interval statistics duration in millisecond.
*
* @since 1.8.0
*/
private int statIntervalMs = 1000;慢调用比例:ResponseTimeCircuitBreaker 用单桶窗口(SlowRequestLeapArray(1, statIntervalMs)——桶数 1,窗口长度 = statIntervalMs)统计“慢于 count 的请求数 / 总数”;exit 时(DegradeSlot 对已通过请求调用 onRequestComplete)逐次判定,totalCount < minRequestAmount 时不判开,比例 > maxSlowRequestRatio 开闸;还有一个特判:比例等于 1.0 且阈值本身是 1.0 也开(否则 > 会把“全慢”漏掉):
@Override
public void onRequestComplete(Context context) {
SlowRequestCounter counter = slidingCounter.currentWindow().value();
Entry entry = context.getCurEntry();
if (entry == null) {
return;
}
long completeTime = entry.getCompleteTimestamp();
if (completeTime <= 0) {
completeTime = TimeUtil.currentTimeMillis();
}
long rt = completeTime - entry.getCreateTimestamp();
if (rt > maxAllowedRt) {
counter.slowCount.add(1);
}
counter.totalCount.add(1);
handleStateChangeWhenThresholdExceeded(rt);
}
private void handleStateChangeWhenThresholdExceeded(long rt) {
if (currentState.get() == State.OPEN) {
return;
}
if (currentState.get() == State.HALF_OPEN) {
// In detecting request
// TODO: improve logic for half-open recovery
if (rt > maxAllowedRt) {
fromHalfOpenToOpen(1.0d);
} else {
fromHalfOpenToClose();
}
return;
}
List<SlowRequestCounter> counters = slidingCounter.values();
long slowCount = 0;
long totalCount = 0;
for (SlowRequestCounter counter : counters) {
slowCount += counter.slowCount.sum();
totalCount += counter.totalCount.sum();
}
if (totalCount < minRequestAmount) {
return;
}
double currentRatio = slowCount * 1.0d / totalCount;
if (currentRatio > maxSlowRequestRatio) {
transformToOpen(currentRatio);
}
if (Double.compare(currentRatio, maxSlowRequestRatio) == 0 &&
Double.compare(maxSlowRequestRatio, SLOW_REQUEST_RATIO_MAX_VALUE) == 0) {
transformToOpen(currentRatio);
}
}这里有一处与直觉相反的代码细节:slowRatioThreshold 的默认值是 1.0,且 1.0 走特判 >= 语义——即“慢调用比例达到 100% 就开”。这等价于“默认相当于只按 RT 阈值做纯慢调用熔断(每个请求都慢才开)”的保守策略;要表达“60% 慢就开”必须显式设 slowRatioThreshold=0.6。常见误读是把 count 当“阈值比例”(RT 模式下 count 是毫秒数,比例阈值是另一个字段),或把 minRequestAmount 当时间窗口(它是次数;时间窗口是 statIntervalMs)。另外注意 ResponseTimeCircuitBreaker 的窗口桶数是 1——没有滑动、只有当前 statIntervalMs 片段的计数,窗口边界处计数直接清零,这一点与流控的 LeapArray(2, 1000) 不同。
异常比例与异常数
异常比例/异常数:同一个 ExceptionCircuitBreaker 承载两种策略(strategy 字段区分),统计口径是 errCount(或 errCount/totalCount)——异常来源是 entry.getError(),与 §滑动窗口 的 Tracer 依赖完全一致:
@Override
protected void resetStat() {
// Reset current bucket (bucket count = 1).
stat.currentWindow().value().reset();
}
@Override
public void onRequestComplete(Context context) {
Entry entry = context.getCurEntry();
if (entry == null) {
return;
}
Throwable error = entry.getError();
SimpleErrorCounter counter = stat.currentWindow().value();
if (error != null) {
counter.getErrorCount().add(1);
}
counter.getTotalCount().add(1);
handleStateChangeWhenThresholdExceeded(error);
}
private void handleStateChangeWhenThresholdExceeded(Throwable error) {
if (currentState.get() == State.OPEN) {
return;
}
if (currentState.get() == State.HALF_OPEN) {
// In detecting request
if (error == null) {
fromHalfOpenToClose();
} else {
fromHalfOpenToOpen(1.0d);
}
return;
}
List<SimpleErrorCounter> counters = stat.values();
long errCount = 0;
long totalCount = 0;
for (SimpleErrorCounter counter : counters) {
errCount += counter.errorCount.sum();
totalCount += counter.totalCount.sum();
}
if (totalCount < minRequestAmount) {
return;
}
double curCount = errCount;
if (strategy == DEGRADE_GRADE_EXCEPTION_RATIO) {
// Use errorRatio
curCount = errCount * 1.0d / totalCount;
}
if (curCount > threshold) {
transformToOpen(curCount);
}
}两策略共用“最小样本不足不判开”(对应通用页 59 行“最小样本不足不能用一次失败判 100%”),但阈值比较是严格大于:异常比例 count=0.5 时恰好 50% 不开闸;异常数 count=3 时恰好 3 次不满足。异常必须经 Tracer 记账(§滑动窗口),BlockException 被源码显式排除——熔断器看到的是业务失败,看不到“被限流”;这也意味着“Flow 拒绝导致的失败”不会反过来触发熔断,正是通用页 61 行“熔断指标必须与真实下游错误分开”的实现。公开实验 A7 覆盖 RT 慢比例与异常比例两条路径 + “不 trace 永不开”的负控(degrade 观察行,结果以证据为准)。
熔断状态机与半开恢复
三状态 CLOSED/OPEN/HALF_OPEN 与通用页 49–61 行的模型一一对应。先看接线——DegradeSlot 把状态机接进 Slot 链:entry 时对每个熔断器 tryPass,exit 时只对已通过请求调用 onRequestComplete(被拒请求不计入熔断窗口):
@Spi(order = Constants.ORDER_DEGRADE_SLOT)
public class DegradeSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
boolean prioritized, Object... args) throws Throwable {
performChecking(context, resourceWrapper);
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
void performChecking(Context context, ResourceWrapper r) throws BlockException {
List<CircuitBreaker> circuitBreakers = DegradeRuleManager.getCircuitBreakers(r.getName());
if (circuitBreakers == null || circuitBreakers.isEmpty()) {
return;
}
for (CircuitBreaker cb : circuitBreakers) {
if (!cb.tryPass(context)) {
throw new DegradeException(cb.getRule().getLimitApp(), cb.getRule());
}
}
}
@Override
public void exit(Context context, ResourceWrapper r, int count, Object... args) {
Entry curEntry = context.getCurEntry();
if (curEntry.getBlockError() != null) {
fireExit(context, r, count, args);
return;
}
List<CircuitBreaker> circuitBreakers = DegradeRuleManager.getCircuitBreakers(r.getName());
if (circuitBreakers == null || circuitBreakers.isEmpty()) {
fireExit(context, r, count, args);
return;
}
if (curEntry.getBlockError() == null) {
// passed request
for (CircuitBreaker circuitBreaker : circuitBreakers) {
circuitBreaker.onRequestComplete(context);
}
}
fireExit(context, r, count, args);
}接下来是状态机本体——三个必须绑定源码的细节:懒转换(无后台线程)、半开单探测(不是半开窗口)、探测被后续规则拦截时回退 OPEN(#1638 的 workaround):
@Override
public DegradeRule getRule() {
return rule;
}
@Override
public State currentState() {
return currentState.get();
}
@Override
public boolean tryPass(Context context) {
// Template implementation.
if (currentState.get() == State.CLOSED) {
return true;
}
if (currentState.get() == State.OPEN) {
// For half-open state we allow a request for probing.
return retryTimeoutArrived() && fromOpenToHalfOpen(context);
}
return false;
}
/**
* Reset the statistic data.
*/
abstract void resetStat();
protected boolean retryTimeoutArrived() {
return TimeUtil.currentTimeMillis() >= nextRetryTimestamp;
}
protected void updateNextRetryTimestamp() {
this.nextRetryTimestamp = TimeUtil.currentTimeMillis() + recoveryTimeoutMs;
}
protected boolean fromCloseToOpen(double snapshotValue) {
State prev = State.CLOSED;
if (currentState.compareAndSet(prev, State.OPEN)) {
updateNextRetryTimestamp();
notifyObservers(prev, State.OPEN, snapshotValue);
return true;
}
return false;
}
protected boolean fromOpenToHalfOpen(Context context) {
if (currentState.compareAndSet(State.OPEN, State.HALF_OPEN)) {
notifyObservers(State.OPEN, State.HALF_OPEN, null);
Entry entry = context.getCurEntry();
entry.whenTerminate(new BiConsumer<Context, Entry>() {
@Override
public void accept(Context context, Entry entry) {
// Note: This works as a temporary workaround for https://github.com/alibaba/Sentinel/issues/1638
// Without the hook, the circuit breaker won't recover from half-open state in some circumstances
// when the request is actually blocked by upcoming rules (not only degrade rules).
if (entry.getBlockError() != null) {
// Fallback to OPEN due to detecting request is blocked
currentState.compareAndSet(State.HALF_OPEN, State.OPEN);
notifyObservers(State.HALF_OPEN, State.OPEN, 1.0d);
}
}
});
return true;
}
return false;
}逐条读:
- 无后台自动转换。OPEN 状态不会自己跳到 HALF_OPEN:
tryPass在下一个请求到达时才检查retryTimeoutArrived()(nextRetryTimestamp = 开闸时刻 + timeWindow×1000,AbstractCircuitBreaker.java:54),没到时间一律拒(return false→DegradeException)。与 R4j 的automaticTransitionFromOpenToHalfOpenEnabled=false默认同构:都是“懒触发”。没有流量 = 永远 OPEN,直到有请求来试探。 - 半开单探测。
fromOpenToHalfOpen用一次compareAndSet(OPEN, HALF_OPEN)——并发下只有一个请求能拿到探测权,其余请求在 HALF_OPEN 状态看到tryPass返回 false(走最后的return false)。这里没有“半开放行 N 次”的配置(R4j 的permittedNumberOfCallsInHalfOpenState默认 10 次在 Sentinel 不存在对应物)。探测请求的结局由 exit 时的onRequestComplete决定:成功(RT 未超/无异常)→fromHalfOpenToClose();失败 →fromHalfOpenToOpen()并重置nextRetryTimestamp。 - 探测被后续规则拦截 → 回退 OPEN(#1638)。DegradeSlot 排在链尾(order -1000),当同一个资源还有多条熔断规则(如 R1 到期先半开、R2 仍 OPEN)或后面还有别的规则拒绝时,探测请求被别的规则拒掉,
whenTerminate回调看到entry.getBlockError() != null,把 HALF_OPEN 拿回 OPEN——否则熔断器会永远卡在半开(探测请求根本没执行,onRequestComplete永不触发)。这是“多个规则叠加时半开恢复”的关键补丁;issue #1638 的原文描述正是 R1(OPEN→HALF_OPEN) 被 R2(OPEN) 拒绝后 R1 永久半开。 - 规则相等则复用熔断器。
DegradeRuleManager.getExistingSameCbOrNew:145-156:新规则数组里与旧规则equals(所有字段相同)的,直接返回旧 CircuitBreaker 实例——状态不丢;规则一改(哪怕只改阈值),旧 CB 被丢弃、新 CB 从 CLOSED 起步。这解释了“改规则后熔断状态清零”的运维现象,也是“热更新规则不安全”的一个真实边界。
状态机可复述为:
CLOSED --窗口内样本≥minRequestAmount 且 比例/计数超阈值--> OPEN(记录 nextRetryTimestamp)
OPEN --请求到达且 now ≥ nextRetryTimestamp(CAS 单探测)--> HALF_OPEN
HALF_OPEN --探测成功--> CLOSED(resetStat)
HALF_OPEN --探测失败或探测被后续规则拒绝(whenTerminate)--> OPEN(重置计时)
OPEN/HALF_OPEN 期间其余请求 --tryPass=false--> DegradeException两个反例:①以为“OPEN 到期会自己恢复”(没有流量就没有探测,永远 OPEN——对低频依赖等于熔断永续,需要监控主动探测或接受“下一个真实请求承担探测”);②以为半开“放一批请求看比例”(那是 R4j 的半开窗口语义;Sentinel 只放一个,一次成功即关闭、一次失败即重开)。§Sentinel 与 Resilience4j 的对照表会把这两行并列。公开实验 A7 的 degrade 观察行覆盖“open → timeWindow → 单探测失败回 open → 再探测成功回 closed”与“探测同时命中 FlowRule 回 open”两条链(结果以证据为准)。
热点参数限流
热点参数是第五类规则、独立模块(sentinel-parameter-flow-control),在 slot 链中以 @Spi(order = -3000) 排在 System 与 Flow 之间。SphU.entry(name, EntryType, count, args...) 把业务参数带进来,ParamFlowRule.paramIdx 指定看第几个参数(负数从尾部解),count 是该参数值的热点阈值:
@Spi(order = -3000)
public class ParamFlowSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
boolean prioritized, Object... args) throws Throwable {
checkFlow(resourceWrapper, count, args);
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
@Override
public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
fireExit(context, resourceWrapper, count, args);
}
void applyRealParamIdx(/*@NonNull*/ ParamFlowRule rule, int length) {
int paramIdx = rule.getParamIdx();
if (paramIdx < 0) {
if (-paramIdx <= length) {
rule.setParamIdx(length + paramIdx);
} else {
// Illegal index, give it a illegal positive value, latter rule checking will pass.
rule.setParamIdx(-paramIdx);
}
}
}
void checkFlow(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException {
if (args == null) {
return;
}
if (!ParamFlowRuleManager.hasRules(resourceWrapper.getName())) {
return;
}
List<ParamFlowRule> rules = ParamFlowRuleManager.getRulesOfResource(resourceWrapper.getName());
for (ParamFlowRule rule : rules) {
applyRealParamIdx(rule, args.length);
// Initialize the parameter metrics.
ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);
if (!ParamFlowChecker.passCheck(resourceWrapper, rule, count, args)) {
String triggeredParam = "";
if (args.length > rule.getParamIdx()) {
Object value = args[rule.getParamIdx()];
// Assign actual value with the result of paramFlowKey method
if (value instanceof ParamFlowArgument) {
value = ((ParamFlowArgument) value).paramFlowKey();
}
triggeredParam = String.valueOf(value);
}
throw new ParamFlowException(resourceWrapper.getName(), triggeredParam, rule);
}
}
}规则字段(独立阈值、例外项、burst、窗口长度、控制行为):
/**
* The threshold type of flow control (0: thread count, 1: QPS).
*/
private int grade = RuleConstant.FLOW_GRADE_QPS;
/**
* Parameter index.
*/
private Integer paramIdx;
/**
* The threshold count.
*/
private double count;
/**
* Traffic shaping behavior (since 1.6.0).
*/
private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT;
private int maxQueueingTimeMs = 0;
private int burstCount = 0;
private long durationInSec = 1;
/**
* Original exclusion items of parameters.
*/
private List<ParamFlowItem> paramFlowItemList = new ArrayList<ParamFlowItem>();
/**
* Parsed exclusion items of parameters. Only for internal use.
*/
private Map<Object, Integer> hotItems = new HashMap<Object, Integer>();
/**
* Indicating whether the rule is for cluster mode.
*/
private boolean clusterMode = false;
/**
* Cluster mode specific config for parameter flow rule.
*/
private ParamFlowClusterConfig clusterConfig;分支判定在 ParamFlowChecker:QPS 档默认走“令牌补充”实现(passDefaultLocalCheck),匀速档走按参数值的 passThrottleLocalCheck;线程档按参数值维度的线程数计数。三条放行短路先于一切检查:args == null、args.length <= paramIdx(参数不足)、value == null;负数索引解出来仍越界时,applyRealParamIdx 把它改成一个“非法正值”,后面的 args.length <= paramIdx 检查自然放行:
static boolean passSingleValueCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int acquireCount,
Object value) {
if (rule.getGrade() == RuleConstant.FLOW_GRADE_QPS) {
if (rule.getControlBehavior() == RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER) {
return passThrottleLocalCheck(resourceWrapper, rule, acquireCount, value);
} else {
return passDefaultLocalCheck(resourceWrapper, rule, acquireCount, value);
}
} else if (rule.getGrade() == RuleConstant.FLOW_GRADE_THREAD) {
Set<Object> exclusionItems = rule.getParsedHotItems().keySet();
long threadCount = getParameterMetric(resourceWrapper).getThreadCount(rule.getParamIdx(), value);
if (exclusionItems.contains(value)) {
int itemThreshold = rule.getParsedHotItems().get(value);
return ++threadCount <= itemThreshold;
}
long threshold = (long) rule.getCount();
return ++threadCount <= threshold;
}“独立阈值”的语义:count 是每个参数值各自的 QPS 上限(不是全局共享)——uid=42 和 uid=43 各有自己的 5 QPS。paramFlowItemList(parsed 后成 hotItems)为特定参数值覆盖阈值(注意是覆盖不是叠加:例外项与全局是同一套桶,只换阈值);burstCount 是允许的突发余量(passDefaultLocalCheck 里 maxCount = tokenCount + burstCount,ParamFlowChecker.java:145,同时 acquireCount > maxCount 直接拒)。匀速档的排队实现与 §匀速排队 同构但按参数值分桶、CAS 时间戳:
static boolean passThrottleLocalCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int acquireCount,
Object value) {
ParameterMetric metric = getParameterMetric(resourceWrapper);
CacheMap<Object, AtomicLong> timeRecorderMap = metric == null ? null : metric.getRuleTimeCounter(rule);
if (timeRecorderMap == null) {
return true;
}
// Calculate max token count (threshold)
Set<Object> exclusionItems = rule.getParsedHotItems().keySet();
long tokenCount = (long) rule.getCount();
if (exclusionItems.contains(value)) {
tokenCount = rule.getParsedHotItems().get(value);
}
if (tokenCount == 0) {
return false;
}
long costTime = Math.round(1.0 * 1000 * acquireCount * rule.getDurationInSec() / tokenCount);
while (true) {
long currentTime = TimeUtil.currentTimeMillis();
AtomicLong timeRecorder = timeRecorderMap.putIfAbsent(value, new AtomicLong(currentTime));
if (timeRecorder == null) {
return true;
}
//AtomicLong timeRecorder = timeRecorderMap.get(value);
long lastPassTime = timeRecorder.get();
long expectedTime = lastPassTime + costTime;
if (expectedTime <= currentTime || expectedTime - currentTime < rule.getMaxQueueingTimeMs()) {
AtomicLong lastPastTimeRef = timeRecorderMap.get(value);
if (lastPastTimeRef.compareAndSet(lastPassTime, currentTime)) {
long waitTime = expectedTime - currentTime;
if (waitTime > 0) {
lastPastTimeRef.set(expectedTime);
try {
TimeUnit.MILLISECONDS.sleep(waitTime);
} catch (InterruptedException e) {
RecordLog.warn("passThrottleLocalCheck: wait interrupted", e);
}
}
return true;
} else {
Thread.yield();
}
} else {
return false;
}
}
}内存边界必须讲:参数值来自业务数据(用户 ID、IP、商品 ID),每个值都要在 ParameterMetric 里建桶;CacheMap 是有容量上限的 ConcurrentLinkedHashMapWrapper——时间/令牌桶容量 min(4000 × durationInSec, 200000),线程数桶容量 4000(ParameterMetric.java:38-40,102,121)。超容量按 LRU 淘汰,被淘汰参数的计数会从零重新开始——高基数参数(如把时间戳、UUID 当参数)会把容量耗尽并让热点头部反复被淘汰,表现为“热点限流时灵时不灵”。这是“以为参数统计无内存上限”的反例。另一个反例:ParamFlowRule 的 durationInSec 是统计窗口长度(默认 1s),不是“持续多少秒后开始限流”。公开实验 A8 的 paramFlow 观察行覆盖热点 item 单独阈值、越界/负索引放行与匀速排队(结果以证据为准)。
系统自适应保护与授权黑白名单
系统规则是唯一一条“入口级”规则:只看全局入站统计 Constants.ENTRY_NODE(资源名 __total_inbound_traffic__,Constants.java:45),且只对 EntryType.IN 生效——OUT 资源配系统规则永远不会触发。五道闸门按顺序检查,任一超限即抛 SystemBlockException,load 分支走 BBR 自适应而不是直接拒:
public static void checkSystem(ResourceWrapper resourceWrapper, int count) throws BlockException {
if (resourceWrapper == null) {
return;
}
// Ensure the checking switch is on.
if (!checkSystemStatus.get()) {
return;
}
// for inbound traffic only
if (resourceWrapper.getEntryType() != EntryType.IN) {
return;
}
// total qps
double currentQps = Constants.ENTRY_NODE.passQps();
if (currentQps + count > qps) {
throw new SystemBlockException(resourceWrapper.getName(), "qps");
}
// total thread
int currentThread = Constants.ENTRY_NODE.curThreadNum();
if (currentThread > maxThread) {
throw new SystemBlockException(resourceWrapper.getName(), "thread");
}
double rt = Constants.ENTRY_NODE.avgRt();
if (rt > maxRt) {
throw new SystemBlockException(resourceWrapper.getName(), "rt");
}
// load. BBR algorithm.
if (highestSystemLoadIsSet && getCurrentSystemAvgLoad() > highestSystemLoad) {
if (!checkBbr(currentThread)) {
throw new SystemBlockException(resourceWrapper.getName(), "load");
}
}
// cpu usage
if (highestCpuUsageIsSet && getCurrentCpuUsage() > highestCpuUsage) {
throw new SystemBlockException(resourceWrapper.getName(), "cpu");
}
}四个实现细节:①开关不是自动的——checkSystemStatus 只有在至少一条系统规则的字段被设置后才为 true(SystemRuleManager.loadSystemConf(242–280 行)的 checkStatus 汇总);规则集清空即回 false,五道闸门全部跳过。②load 分支的 BBR 判定(checkBbr,334–340 行):currentThread > 1 && currentThread > maxSuccessQps × minRt / 1000 才拒——即“系统 load 高但当前在途没超过‘成功 QPS × 最小 RT’的容量估计”时放行,这是 load 指标噪声大、不能直接当硬阈值用的让步。③highestCpuUsage 取值必须在 (0,1],>1 的规则会被记录警告并忽略(loadSystemConf 252–253 行)。④cpu/load 读数来自 SystemStatusListener(com.sun.management.OperatingSystemMXBean;JDK 21 上 getSystemCpuLoad 已标 deprecated 但可用)——这部分读数依赖运行平台,本页实验只断言 qps/thread/rt 三个确定性闸门,load/cpu 记录本机实测值、不作为跨机器结论。
反例两条:①给 OUT 资源(如出站 HTTP 客户端资源)配系统规则并期待生效——EntryType 检查直接 return;②把系统规则当“资源级规则”调——它读的是全站入站合计,单资源流量只占其中一部分,阈值语义完全不同(例:全站 QPS 上限 1000,单个资源 200 QPS 规则是两回事,见 §流控)。
授权规则(黑白名单)判断的是 Context 的 origin——调用方应用名,不是用户身份。空 origin 或空名单直接放行;名单是逗号分隔的精确匹配(先子串命中再逐项 equals,子串不是精确值会被排除);黑名单命中拒、白名单不含拒:
授权黑白名单
final class AuthorityRuleChecker {
static boolean passCheck(AuthorityRule rule, Context context) {
String requester = context.getOrigin();
// Empty origin or empty limitApp will pass.
if (StringUtil.isEmpty(requester) || StringUtil.isEmpty(rule.getLimitApp())) {
return true;
}
// Do exact match with origin name.
int pos = rule.getLimitApp().indexOf(requester);
boolean contain = pos > -1;
if (contain) {
boolean exactlyMatch = false;
String[] appArray = rule.getLimitApp().split(",");
for (String app : appArray) {
if (requester.equals(app)) {
exactlyMatch = true;
break;
}
}
contain = exactlyMatch;
}
int strategy = rule.getStrategy();
if (strategy == RuleConstant.AUTHORITY_BLACK && contain) {
return false;
}
if (strategy == RuleConstant.AUTHORITY_WHITE && !contain) {
return false;
}
return true;
}三个误读正是本条的 K05 反例:①以为 origin 是用户 ID / 租户(它是 ContextUtil.enter(name, origin) 传入的调用方应用名,通常由适配器从服务标识填;把终端用户当 origin 会让 origin 节点数随用户数膨胀,见 §资源 的内存警示);②以为“没配 origin 会被默认拒绝”(空 origin 直接放行——白名单只对已声明身份的调用方生效,未声明身份者绕过授权规则;所以授权规则不是认证机制,不能替代鉴权);③以为名单支持前缀/通配(只支持逗号分隔精确值,app 不会匹配 app-v2)。公开实验 A9 的 systemAuthority 观察行覆盖 IN/OUT 差异、qps/thread 闸门与黑白名单×origin(结果以证据为准)。
集群流控与 fallback
单机规则的阈值是每实例的:3 个副本各配 100 QPS,集群总量就是 300。集群流控把判定集中到一个 token server(sentinel-cluster-server-default,默认端口 18730,ClusterConstants.DEFAULT_CLUSTER_SERVER_PORT),客户端按 flowId 请求 token。服务端的判定核心是“全局阈值 × exceedCount − 当前已通过量”:
private static double calcGlobalThreshold(FlowRule rule) {
double count = rule.getCount();
switch (rule.getClusterConfig().getThresholdType()) {
case ClusterRuleConstant.FLOW_THRESHOLD_GLOBAL:
return count;
case ClusterRuleConstant.FLOW_THRESHOLD_AVG_LOCAL:
default:
int connectedCount = ClusterFlowRuleManager.getConnectedCount(rule.getClusterConfig().getFlowId());
return count * connectedCount;
}
}
static boolean allowProceed(long flowId) {
String namespace = ClusterFlowRuleManager.getNamespace(flowId);
return GlobalRequestLimiter.tryPass(namespace);
}
static TokenResult acquireClusterToken(/*@Valid*/ FlowRule rule, int acquireCount, boolean prioritized) {
Long id = rule.getClusterConfig().getFlowId();
if (!allowProceed(id)) {
return new TokenResult(TokenResultStatus.TOO_MANY_REQUEST);
}
ClusterMetric metric = ClusterMetricStatistics.getMetric(id);
if (metric == null) {
return new TokenResult(TokenResultStatus.FAIL);
}
double latestQps = metric.getAvg(ClusterFlowEvent.PASS);
double globalThreshold = calcGlobalThreshold(rule) * ClusterServerConfigManager.getExceedCount();
double nextRemaining = globalThreshold - latestQps - acquireCount;
if (nextRemaining >= 0) {
// TODO: checking logic and metric operation should be separated.
metric.add(ClusterFlowEvent.PASS, acquireCount);
metric.add(ClusterFlowEvent.PASS_REQUEST, 1);
if (prioritized) {
// Add prioritized pass.
metric.add(ClusterFlowEvent.OCCUPIED_PASS, acquireCount);
}
// Remaining count is cut down to a smaller integer.
return new TokenResult(TokenResultStatus.OK)
.setRemaining((int) nextRemaining)
.setWaitInMs(0);
} else {
if (prioritized) {
// Try to occupy incoming buckets.
double occupyAvg = metric.getAvg(ClusterFlowEvent.WAITING);
if (occupyAvg <= ClusterServerConfigManager.getMaxOccupyRatio() * globalThreshold) {
int waitInMs = metric.tryOccupyNext(ClusterFlowEvent.PASS, acquireCount, globalThreshold);
// waitInMs > 0 indicates pre-occupy incoming buckets successfully.
if (waitInMs > 0) {
ClusterServerStatLogUtil.log("flow|waiting|" + id);
return new TokenResult(TokenResultStatus.SHOULD_WAIT)
.setRemaining(0)
.setWaitInMs(waitInMs);
}
// Or else occupy failed, should be blocked.
}
}
// Blocked.
metric.add(ClusterFlowEvent.BLOCK, acquireCount);
metric.add(ClusterFlowEvent.BLOCK_REQUEST, 1);
ClusterServerStatLogUtil.log("flow|block|" + id, acquireCount);
ClusterServerStatLogUtil.log("flow|block_request|" + id, 1);
if (prioritized) {
// Add prioritized block.
metric.add(ClusterFlowEvent.OCCUPIED_BLOCK, acquireCount);
ClusterServerStatLogUtil.log("flow|occupied_block|" + id, 1);
}
return blockedResult();
}读法:①thresholdType 两档——FLOW_THRESHOLD_GLOBAL 直接使用 count,AVG_LOCAL(默认)按“count × 已连接客户端数”折算全局阈值(每个新客户端接入自动放大总量,这是“单机阈值 × 在线实例数”语义);②exceedCount(默认 1.0,ServerFlowConfig.DEFAULT_EXCEED_COUNT)是阈值余量倍数,maxOccupyRatio(默认 1.0)限制优先级请求能占用多少未来额度;③SHOULD_WAIT 返回等待毫秒数,客户端 applyTokenResult 里 Thread.sleep 后放行(FlowRuleChecker.java:192-197)。
fallback 语义必须按 1.8.9 源码写:passClusterCheck 在拿不到 token service(未启用集群、无客户端/服务端)或请求异常时,进入 fallbackToLocalOrPass——ClusterFlowConfig.fallbackToLocalWhenFail 默认 true(ClusterFlowConfig.java:40),即回退到本地规则继续判定;只有显式设为 false 时才“直接放行”:
原码节选(同一固定 commit,FlowRuleChecker.java:147-174,与上文 checkFlow/canPassCheck 摘录同文件、连续原文):
private static boolean passClusterCheck(FlowRule rule, Context context, DefaultNode node, int acquireCount,
boolean prioritized) {
try {
TokenService clusterService = pickClusterService();
if (clusterService == null) {
return fallbackToLocalOrPass(rule, context, node, acquireCount, prioritized);
}
long flowId = rule.getClusterConfig().getFlowId();
TokenResult result = clusterService.requestToken(flowId, acquireCount, prioritized);
return applyTokenResult(result, rule, context, node, acquireCount, prioritized);
// If client is absent, then fallback to local mode.
} catch (Throwable ex) {
RecordLog.warn("[FlowRuleChecker] Request cluster token unexpected failed", ex);
}
// Fallback to local flow control when token client or server for this rule is not available.
// If fallback is not enabled, then directly pass.
return fallbackToLocalOrPass(rule, context, node, acquireCount, prioritized);
}
private static boolean fallbackToLocalOrPass(FlowRule rule, Context context, DefaultNode node, int acquireCount,
boolean prioritized) {
if (rule.getClusterConfig().isFallbackToLocalWhenFail()) {
return passLocalCheck(rule, context, node, acquireCount, prioritized);
} else {
// The rule won't be activated, just pass.
return true;
}
}所以故障面的完整表述是:普通流控的集群模式默认 fail-safe 到本地规则(阈值退化为单机 count);把它关掉(setFallbackToLocalWhenFail(false))才变成 fail-open——token server 故障期间所有请求直接放过。这与 U32 页 161–186 行 Redis 令牌桶的 fail-open 是两个可选策略的对照:Redis 限流器故障默认放行((1,-1),无本地兜底),Sentinel 集群流控默认回退本地、显式关闭后放行;两者共同揭示“远程配额源的故障语义必须显式选择并观测”。另一处细节:参数流控的集群模式默认相反——ParamFlowClusterConfig.fallbackToLocalWhenFail 默认 false(ParamFlowClusterConfig.java:38),即热点参数集群规则在 token 服务不可用时默认直接放行。同一个库内两个集群 fallback 默认值相反,是配置审查时最容易被漏掉的一行。
applyTokenResult 还把服务端返回的 TOO_MANY_REQUEST/NO_RULE_EXISTS/BAD_REQUEST/FAIL 统一走 fallback 分支,只有 BLOCKED 是确定性拒绝——服务端“不认识这个 flowId”不会变成全量拒绝。生产 token server 部署、跨实例一致性与 Netty 端口规划本页未运行(推演),公开实验只覆盖到单 JVM 内可复现的 fallback 分支语义。flowId 是全局唯一数字,规则里 flowId 配置错/漏配时 FlowRuleUtil 校验会拒绝规则。
规则数据源与动态生效
所有规则的默认存储都是内存:FlowRuleManager.loadRules(List) 一次性替换整个规则集,进程重启即丢失;register2Property(SentinelProperty) 把规则来源换成一个可监听属性,数据源模块监听外部变化后 updateValue(newRules),规则管理器收到通知后原子替换。数据源是 SPI 化的 ReadableDataSource,sentinel-datasource-extension 提供文件与自动刷新基类,nacos/apollo/zookeeper/redis/consul/etcd 等为独立工件。文件数据源是唯一在本机完整实跑的动态规则证据(公开实验 A11):
// 文件内容变化 → reparse → updateValue → FlowRuleManager 监听器替换规则,下一次 entry 生效
ReadableDataSource<String, List<FlowRule>> ds = new FileRefreshableDataSource<>(
new File("sentinel-rules.json"), source -> JSON.parseArray(source, FlowRule.class));
FlowRuleManager.register2Property(ds.getProperty());FileRefreshableDataSource 构造时立即首读一次(firstLoad()),AutoRefreshDataSource 的默认轮询间隔是 3000ms(DEFAULT_REFRESH_MS = 3000,FileRefreshableDataSource.java:42;AutoRefreshDataSource.recommendRefreshMs = 3000)——即“改文件后最多约 3 秒生效”,不是毫秒级推送。推演面(本页未运行):
- Nacos 数据源:
NacosDataSource构造时注册configService.addListener(dataId, groupId, listener)(NacosDataSource.java:129),初始loadConfig一次(115 行),之后每次配置变更回调里getProperty().updateValue(newValue)(106 行)。这是推模式(服务端推送通知 + 客户端拉取),与文件源的轮询语义不同;Nacos 服务端一致性机制归 U40 的 Nacos 注册发现与配置一致性,本页只登记客户端监听→属性通知链。 - Dashboard 通道:独立控制台应用,经
sentinel-transport-simple-http与应用通信——应用侧默认命令端口 8719(SimpleHttpCommandCenter.java:53,csp.sentinel.api.port可改;它不是 Dashboard 的端口),Dashboard 地址由应用侧csp.sentinel.dashboard.server指定(TransportConfig.java:35)。命令端点getRules(FetchActiveRuleCommandHandler)与setRules(ModifyRulesCommandHandler,@CommandMapping(name = "setRules", ...))读写规则;修改只落在内存,要持久化必须注册WritableDataSource(写回文件/配置中心),否则应用重启即回旧规则。Dashboard 本页不部署、不运行。 - 三种持久化观的边界:
loadRules内存态 ≠ 数据源拉取态 ≠ 数据源存储态。排障时按“数据源最新值 → 属性监听通知 → 规则管理器当前集”三段核对,任何一段断链都表现为“规则改了没生效”。公开实验 A11 的fileDatasource观察行覆盖“改文件→毫秒/秒级生效→回滚”(具体时延以证据为准,理论下限是轮询间隔 3s 或文件 mtime 变化策略)。
@SentinelResource 与 blockHandler/fallback
注解是编程式 API 的 AOP 包装:SentinelResourceAspect 在 @Around 里执行 SphU.entry(resourceName, resourceType, entryType, args),proceed() 返回即通过;BlockException 走 blockHandler(未配置则继续走 fallback,fallback 也没有则原样抛出);业务异常先查 exceptionsToIgnore(命中直接抛、不记 trace、不走 fallback),再查 exceptionsToTrace(默认全部 Throwable,命中则 traceException + fallback);finally 里 entry.exit(1, args):
@Aspect
public class SentinelResourceAspect extends AbstractSentinelAspectSupport {
@Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)")
public void sentinelResourceAnnotationPointcut() {
}
@Around("sentinelResourceAnnotationPointcut()")
public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable {
Method originMethod = resolveMethod(pjp);
SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class);
if (annotation == null) {
// Should not go through here.
throw new IllegalStateException("Wrong state for SentinelResource annotation");
}
String resourceName = getResourceName(annotation.value(), originMethod);
EntryType entryType = annotation.entryType();
int resourceType = annotation.resourceType();
Entry entry = null;
try {
entry = SphU.entry(resourceName, resourceType, entryType, pjp.getArgs());
return pjp.proceed();
} catch (BlockException ex) {
return handleBlockException(pjp, annotation, ex);
} catch (Throwable ex) {
Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore();
// The ignore list will be checked first.
if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) {
throw ex;
}
if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) {
traceException(ex);
return handleFallback(pjp, annotation, ex);
}
// No fallback function can handle the exception, so throw it out.
throw ex;
} finally {
if (entry != null) {
entry.exit(1, pjp.getArgs());
}
}
}两条通路的签名纪律(AbstractSentinelAspectSupport.handleBlockException/handleFallback):blockHandler 必须是“原方法参数 + 末尾一个 BlockException 参数”;fallback 是“原方法参数 + 可选末尾一个 Throwable”;defaultFallback 只允许零参或单个 Throwable,且要求返回类型兼容。blockHandlerClass/fallbackClass 指定的类里方法必须是 static(非 static 会在解析时找不到该方法)。entryType 默认 EntryType.OUT(SentinelResource.java:42),要进入系统规则统计必须显式 entryType = EntryType.IN。
最有教学价值的是 self-invocation 反例:@SentinelResource 走 Spring AOP 代理,同类方法内部 this.other() 调用不经过代理,注解完全失效——资源不 entry、规则不生效、blockHandler 不触发。这与 AOP 与 JDK 代理 的自调用边界同源(U17 的 AOP切面_参数绑定_顺序与AspectJ编织.md 有实测),本页只登记“注解路径的失效面”,代理机制不重讲。规避方式三条:把被注解方法放独立 Bean、注入自身代理再调用、或退回编程式 SphU.entry。
另一个坑:SentinelResourceAspect 使用 AspectJ 注解但不依赖 spring-aop,主代码只 import org.aspectj.lang.*;Spring 下需要 @EnableAspectJAutoProxy + 把 SentinelResourceAspect 注册为 Bean(SCA starter 自动做)。1.8.9 的官方测试基线是 Spring 5.2.21,Framework 7/JDK 21 组合不在官方矩阵内——公开实验 A12 的 annotation 观察行同时覆盖“块→blockHandler”“异常→fallback”“自调用不生效”三条路径(能否通过以证据 JSON 为准,若环境不兼容则按证据如实标注)。
Sentinel 与 Resilience4j
两库解决同一类问题(限流/熔断/隔离),但模型位置完全不同。U33 已给出 R4j 的权威细节(SCB 工厂 923–1009 行、状态机 1017–1174 行、Bulkhead/TimeLimiter 1176–1288 行、指标分工 1399–1414 行),本表只列差异行,不重复 R4j 机制:
| 维度 | Sentinel 1.8.9 | Resilience4j(U33 版本) |
|---|---|---|
| 规则模型 | 字符串资源名 + 集中规则集(loadRules/数据源热更新) | 代码里装饰器实例(每个 CircuitBreaker/RateLimiter 对象自带配置) |
| 生效方式 | Slot 链在 entry 前统一检查;不侵入调用代码(注解或 API) | 包装函数调用(decorate*/SCB run);不包装不生效 |
| 拒绝通道 | BlockException 家族(FlowException/DegradeException/…) | CallNotPermittedException/RequestNotPermitted 等运行时异常 |
| 熔断策略 | 三选一:慢调用比例 / 异常比例 / 异常数 | 失败率 + 慢调用率双阈值同时生效 |
| 最小样本 | minRequestAmount=5 | minimumNumberOfCalls=100(默认,U33 固定源码) |
| 半开恢复 | 单探测(CAS),无半开窗口配置;被后续规则拒绝则回 OPEN(#1638) | permittedNumberOfCallsInHalfOpenState=10 次窗口;无 Sentinel 的探测回退问题(探测被拒不计入) |
| 状态转换 | 懒转换(tryPass 内 retryTimeoutArrived),无后台线程 | 懒转换(tryAcquirePermission),automaticTransitionFromOpenToHalfOpenEnabled=false 默认——同构 |
| 隔离 | 无 Bulkhead 模块;THREAD 流控=快速拒绝并发上限,无等待、无舱壁分组 | Bulkhead(信号量/线程池,maxWaitDuration 可排队);独立模块 |
| 集群 | token server(sentinel-cluster-*),flowId 全局阈值 | 无内建集群配额(依赖外部协调,U33 未覆盖) |
| 动态规则 | 数据源热更新(文件/Nacos/…),推拉两种 | 配置对象重建/监听配置中心自行重建装饰器 |
| 观测口径 | Node 的 pass/block/exception/rt 四计数(秒级滑动窗口) | 事件与 Metrics(失败数/慢调用数/未许可数) |
| SCB 集成 | spring-cloud-circuitbreaker-sentinel(SCA 提供,第三 provider) | spring-cloud-circuitbreaker-resilience4j(Cloud 核心) |
三条最容易混淆的语义差异必须单独强调:①THREAD 流控 ≠ Bulkhead:前者快速拒绝、无等待、无分组;后者可等待、按依赖分池(对应 U33 页 1176 行起一节);②单探测半开 ≠ 半开窗口:Sentinel 一次成功即关、一次失败即开,R4j 放行 N 次后按窗口比例判定——低流量服务在 Sentinel 下恢复更快但更抖,R4j 更平滑但更慢;③BlockException 不在五结局表里:U33 的“一条出站调用的五种结局”描述的是真实网络尝试,Sentinel 的拒绝发生在 entry 之前,是“第零类:未进入业务”的结局,它既不消耗下游资源,也不应计入下游失败率(通用页 61 行、U33 指标分工表同款结论)。两库并跑对照未纳入本票运行范围(推演+两侧源码证据)。
实验读法与旧内容唯一归属
公开实验入口与边界见 Sentinel 实验说明。工程为 Maven 单模块、纯 JVM 测试:真实 sentinel-core/parameter-flow-control/datasource-extension/annotation-aspectj 1.8.9 工件(联网从 Central 拉取,严禁以本机 1.8.6 旧缓存冒充),日志经 -Dcsp.sentinel.log.dir 重定向到临时目录定向清理;不连 MySQL/Redis/Mongo/ES,不启动 Dashboard 与 Docker。证据固定在 labs/sentinel/evidence/u37-2026-10-28.json,通过与否以该文件的观察行为准。约定 12 条固定观察行:
| 观察行 | 覆盖 | 判定口径 |
|---|---|---|
U37 entryContext | Context/EntranceNode/DefaultNode/ClusterNode 调用树、exit 配对 | 与证据比对节点结构与异常类型 |
U37 slotChain | slot 顺序观察与超限分支(源码级) | 以证据为准;6000/2000 常量以源码引用补充 |
U37 slidingWindow | pass/block/exception 计数与 Tracer 负控 | 与证据比对四计数 |
U37 flowQps | QPS 快拒(2 过 3 块)与 prioritized | 与证据比对通过/拒绝序列 |
U37 flowThread | THREAD=1 并发第二请求立即拒 | 与证据比对拒绝类型与时延 |
U37 warmupThrottle | 预热爬坡 + 匀速排队间隔/超时拒 | 以证据为准,不在正文预设数字 |
U37 degrade | RT/异常比例熔断、半开单探测、#1638 回退 | 与证据比对状态序列 |
U37 paramFlow | 热点 item、例外项、越界参数放行 | 与证据比对 |
U37 systemAuthority | IN-only、qps/thread 闸门、黑白名单×origin | 与证据比对 |
U37 fileDatasource | 文件热更新生效与回滚 | 与证据比对时延与生效值 |
U37 annotation | blockHandler/fallback/自调用反例 | 与证据比对三条路径 |
U37 starterBoot4 | SCA starter 在 Boot 4.0.9 的依赖解析/启动 | 结果可能是 =PASS 或 =INCOMPATIBLE-DEDUCED(适配器矩阵外),两种都以证据为准 |
未运行、不得由本页外推的范围:Nacos/Apollo/ZK/Redis 等外部数据源运行行为(本机无这些服务)、Dashboard 控制台部署与推送持久化、生产 token server 部署与跨实例一致性、SCG 5.0.4/WebFlux 网关适配器组合(v6x 适配器钉 SCG 4.3.0/Spring 6.2.7,与基线错配)、Dubbo 3.2.15 与 sentinel-apache-dubbo3-adapter(钉 Dubbo 3.0.9)组合、AHAS/生产控制面、容量与压测数字。这些按源码/配置推演并标注,不把“配置写好了”当“运行通过”。
旧内容的唯一归属不变:超时、重试、熔断、限流与降级 保留 deadline/UNKNOWN/重试放大/熔断状态机/隔离舱/恢复顺序的通用模型(49–75 行),77–92 行“模型未绑定具体库源码”一句由本页承接为 1.8.9 的源码绑定;远程客户端与容错组合:Feign 与 Resilience4j 保留 Feign/SCB 工厂/R4j 状态机与 Bulkach 的权威(923–1288、1399–1426 行),本页只写 Sentinel 第二实现的差异行;Gateway 可靠性 保留网关令牌桶/超时/重试(81–186、188–279 行),182 行“加一层本地限流(如 Sentinel)兜底”的机制解释在 §集群流控 的 fallback 语义;Redis 持久化、高可用与集群 保留 Redis Sentinel 哨兵权威(259–294 行),本页只做页首消歧;Dubbo Filter 链与治理归 Dubbo 调用链与服务治理(U41 目标),Nacos 服务端归 Nacos 注册发现与配置一致性(U40 目标),Seata 已由 Seata:AT、TCC、XA 与 TC 恢复(U38)承接;本页只登记 sentinel-apache-dubbo3-adapter 与 NacosDataSource 的存在与版本事实。U33 页 1426 行与 U32 页 367 行“Sentinel 与 Feign/Gateway 的生产组合未运行”的声明,本页维持:组合语义已按源码讲清,生产组合仍未运行。
闭卷验收
本页 32 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。