1. 为什么要手动用 SphU.entry() 给资源"上户口"
1.1 Sentinel 眼中的"资源"到底是什么
先讲个容易混淆的点。搜索 Sentinel 时,网页很容易先跳出来 Hard Disk Sentinel——那是给硬盘做健康监测的软件,跟我们 Java 后端说的这个流量哨兵完全是两码事。我要聊的是阿里巴巴开源的流量治理组件 Sentinel,中文常被称作"哨兵"。它做限流、熔断、系统保护时,所有规则都作用在一个叫"资源"的概念上。
这个"资源"到底是什么?说白了,就是一段受保护的代码路径,用一个字符串名字来标识。它可以是一个 HTTP 接口的 URL、一个方法名、一条 SQL 语句,甚至可以是你自定义的一段业务编号,比如"createOrder"或"queryUserInfo"。Sentinel 不关心资源背后的逻辑是什么,它只关心两件事:这个资源被调用了多少次、被调用的结果如何。规则就是围绕资源名配置的:这个资源每秒最多允许通过多少次、异常比例达到多少就熔断,等等。
一个类比:资源名就是门禁卡上的卡号,SphU.entry()就是刷卡的行为。门禁系统只认卡号,不认刷卡人长什么样;Sentinel 也只认资源名,不关心你底层业务怎么实现。理解了这层关系,后面一切就顺了。
1.2 注解埋点管不了的三个典型场景
很多人第一反应是:限流不是加个@SentinelResource注解就行了吗?确实,在 Spring 项目里,对某个接口或 Service 方法做限流,注解是最省事的方案。但实际开发中我至少遇到过三种场景,注解压根使不上劲:
第一个是循环体内的依赖调用。比如你要批量处理 100 个用户的积分,循环里调下游积分服务。你想给"单次积分调用"限流,但@SentinelResource加在循环体里的方法上非常别扭;更麻烦的是,资源名是静态的,你没法让第 3 个用户和第 80 个用户区分开。这种地方就必须在循环体内手动埋点。
第二个是资源名需要动态生成的情况。像多租户系统,租户 A 和租户 B 的流量差异悬殊,你希望资源名里带上租户 ID,比如"queryData:tenantA"。注解的资源名是编译期写死的,做不到动态拼装;而编程式埋点可以在运行时把租户 ID 拼进资源名里,规则也能按租户维度去配置。
第三个是非 Spring 环境或框架底层代码。比如你写了一个中间件、一个网关插件,或者项目压根没用 Spring,那 AOP 切面就无从谈起。Sentinel 核心本身不依赖 Spring,它就是一套纯 Java 库,这种情况下必须直接用SphU.entry()。
还有一种更隐蔽的场景:你需要"精确控制返回"——限流降级后不只是抛个异常,而是要根据不同的BlockException类型走不同的兜底逻辑。注解的 fallback 参数虽然能处理,但控制粒度远没有手动 catchBlockException来得细。
1.3 编程式埋点和注解埋点的关系
一句话:注解是"被子"的形态,编程式是"织布"的工艺。@SentinelResource的底层,就是通过SentinelResourceAspect这个切面,在方法执行前调用SphU.entry(),在方法结束后调用entry.exit(),异常时做相应处理。也就是说,你在注解里写的value = "queryUserInfo",本质上就是在给SphU.entry()传的第一个参数赋值。
搞懂编程式埋点,等于把 Sentinel 的地基看明白了。后续不管用注解、用 OpenFeign 集成、用 Spring Cloud Alibaba 适配,遇到诡异问题时你都能往下翻源码,找到真正的原因。
2. SphU.entry() 的完整调用骨架:从创建 Entry 到最终释放
2.1 最小可用示例:一段裸用的代码
直接上一个最典型的调用方式:
import com.alibaba.csp.sentinel.Entry; import com.alibaba.csp.sentinel.SphU; import com.alibaba.csp.sentinel.slots.block.BlockException; public class OrderService { public String createOrder(Long userId) { Entry entry = null; try { // 尝试创建资源入口;如果被流控降级,这里会抛出 BlockException entry = SphU.entry("createOrder"); // 真正的业务逻辑 return doCreateOrder(userId); } catch (BlockException ex) { // 被限流或熔断后走兜底逻辑 return "系统繁忙,请稍后再试"; } finally { // 保证 entry 一定被释放 if (entry != null) { entry.exit(); } } } private String doCreateOrder(Long userId) { // 模拟业务耗时 return "订单创建成功"; } }这个骨架看起来很简单,但里面有三个关键点:try块里调用SphU.entry()并执行业务逻辑;catch里严格只捕获BlockException;finally里对 Entry 判空并调用exit()。三者缺一不可。
可能有朋友问:为什么entry初始值是null?因为SphU.entry()如果被限流,会直接抛异常,根本不会返回对象。在finally里如果不判空直接调用entry.exit(),就会产生NullPointerException,这属于非常低级但很容易踩的坑。
2.2 参数说明:资源名、流量类型、调用计数、参数透传
SphU.entry()有多个重载方法,实际开发中最常用的就是下面这个:
SphU.entry(String resourceName, EntryType entryType, int count, Object... args)各参数含义如下表:
| 参数 | 类型 | 含义 | 使用示例 |
|---|---|---|---|
| resourceName | String | 资源名称,规则匹配的依据 | "createOrder" |
| entryType | EntryType | 流量类型,IN表示入口流量,OUT表示出口流量 | EntryType.IN |
| count | int | 本次调用占用的令牌数,用于按 QPS 或并发线程数限流时累加 | 1 |
| args | Object... | 参数透传,主要用于热点参数限流 | userId |
这里重点说两个容易被忽视的点。第一个是EntryType。入口流量指的是外部请求进入本服务的流量,比如 HTTP 接口;出口流量是指本服务作为调用方调下游依赖的流量,比如RestTemplate调用别的服务。做EntryType.OUT的核心价值是,Sentinel 的系统保护规则里有一项"自适应限流"会参考系统的入口流量和全局 QPS,如果你把外部调用也算成入口,系统保护就会失真。所以约定俗成的做法是:对外提供服务的入口用IN,调用第三方或下游服务的出口用OUT。
第二个是count。默认值是 1,但如果你一个方法内部一次性处理了 10 条消息,这个批次对资源的消耗可以当成 10 次调用,此时把count设成 10 更合理。限流的判断会按累加后的数值去计算 QPS,而不是按调用次数算。
2.3 为什么必须 exit():统计完整性的底层逻辑
很多新手刚接触编程式埋点时,容易觉得exit()是画蛇添足:我entry都调了,规则也生效了,为什么还要专门释放?这不是我形式主义,而是 Sentinel 的设计机制决定了不释放会出大问题。
Sentinel 内部使用 ThreadLocal 保存当前线程的上下文(Context)。每次SphU.entry()时,会通过责任链(Slot Chain)做一系列检查:先做资源统计、再查限流规则、再查熔断规则等。exit()做的事正好相反:它要结束这次调用,把统计结果归入 Node,从上下文中弹出当前 Entry,恢复父级 Entry 作为当前节点。
如果你只entry不exit,会发生两件事:第一,当前线程的 ThreadLocal 里始终挂着这个 Entry,下次同一个线程再调SphU.entry()时,会被当成父子链路套在一起,统计的树形结构错乱。第二,统计节点中的"当前活跃线程数"(curThreadNum)不会递减,并发线程数限流规则会提前误触发。
具体到 Tomcat 线程池这种场景,线程是被复用的。一个请求没exit,线程被归还到池里,下一个请求再拿到这个线程时,ThreadLocal 里还留着上一个请求的 Entry 残留,整个统计就没法看了。所以在finally里释放不是可选项,而是必须项。这就像记账:你记了一笔收入,但永远不把这个账目结清,月底对账时账本上的数字一定是虚高的。
3. 编程式埋点最容易踩的坑与避坑思路
3.1 坑一:Entry 泄漏导致统计数字虚高
这个坑和我上面说的exit()直接相关,但实际发生时往往更隐蔽。有个真实案例:同事在业务代码里写了return之后,finally块确实存在,但entry.exit()被放在了一个条件判断里,比如if (needRecord) { entry.exit(); }。请求走了另一条分支后,Entry 就没被释放。
现象是:接口的 QPS 统计数字一直在涨,限流阈值还没到就提前触发,而且线上流量越低,现象越诡异——低谷期偶尔几个请求就能把阈值打满。排查后才发现是curThreadNum被错误累加。
避坑方法特别简单:entry.exit()不要加任何条件判断,无脑放在finally里。如果你实在需要按条件释放,那更应该检查设计是否有问题。编程式埋点的释放动作应该像try-with-resources一样具有确定性。
3.2 坑二:BlockException 的 catch 顺序写错
这个坑发生在对 Sentinel 不完全了解的同学身上。典型错误是:
try { entry = SphU.entry("createOrder"); // 业务代码 } catch (Exception e) { // 统一异常处理 } catch (BlockException ex) { // 限流兜底逻辑 }这段代码看着没毛病,但 Java 编译直接报错,因为BlockException继承自Exception,catch (Exception e)已经把子类异常全接住了,后面的catch (BlockException ex)永远不会执行,编译器不允许这种不可达的 catch 块。
即使编译器没拦(比如两个 catch 写在不同方法里),运行时也会出现限流之后走了"业务异常"分支而不是"限流兜底"分支的结果。正确写法:
} catch (BlockException ex) { // 被限流/降级,走兜底 return "系统繁忙"; } catch (Exception e) { // 业务异常,要通过 Tracer.trace() 上报给 Sentinel Tracer.trace(e); throw e; }这里还有个隐性问题:业务异常必须上报给 Sentinel,否则熔断降级规则无法感知"异常比例"和"异常数"。有些同学用SphU埋点后,业务方法抛出异常,直接让异常抛出去,但没有调Tracer.trace(e)。结果就是熔断规则永远不触发,因为 Sentinel 根本不知道这个资源在失败。这一点在注解埋点里是自动做的,编程式埋点就得自己动手。
3.3 坑三:同一线程多次 entry 与 exit 的配对错乱
复杂一点的业务里,一个线程可能需要连续进入多个资源。比如先进入"queryOrder",然后调用下游之前进入"callProductService"。此时entry和exit的配对顺序有讲究:必须后进先出(LIFO),也就是后entry的先exit。
反例代码:
Entry entryA = null; Entry entryB = null; try { entryA = SphU.entry("queryOrder"); entryB = SphU.entry("callProductService"); // 业务逻辑 } finally { if (entryA != null) entryA.exit(); // 错误:应该先 exit entryB if (entryB != null) entryB.exit(); }这种错乱会导致什么后果?exit()时 Sentinel 会在当前线程的上下文里找最近的 Entry 做收尾。如果你先 exit 了 A,但 A 已经不是栈顶的 Entry 了,它会尝试找 parent 链,最终可能导致资源统计挂错节点,甚至抛出IllegalStateException。
正确的方式是:后进先出。finally里先exit(entryB),再exit(entryA)。更稳妥的做法是把exit写在一个单独的小方法里,用逆序释放。如果业务逻辑复杂到已经搞不清嵌套层级了,建议在finally块中按相反顺序逐级释放,别图省事。
3.4 坑四:异步线程里直接用 SphU 拿不到上下文
这是高级问题,但也最常见。项目用了线程池、CompletableFuture、MQ 消费者等异步模型时,如果直接在异步任务里调SphU.entry(),会发现资源虽然能进入,但链路信息对不上,甚至热点参数限流规则失效。
原因很简单:Sentinel 的 Context 是通过 ThreadLocal 保存的,父子线程之间天然不共享。你在请求线程里 enter 了 Context,到了异步线程里,ThreadLocal 是空的,SphU.entry()会创建一个默认 Context,和原来的链路没关系。
解决办法有两种。第一种是使用ContextUtil.runOnContext(context, runnable)显式包装异步任务,让子线程继承父线程的 Context。第二种是使用 Sentinel 专门提供的AsyncEntry支持:
AsyncEntry entry = null; try { entry = SphU.asyncEntry("asyncResource"); CompletableFuture.runAsync(() -> { // 异步业务逻辑 try { // 注意:异步回调里不再需要 entry,但业务异常要上报 } catch (Exception e) { Tracer.trace(e); } }).whenComplete((r, ex) -> { if (entry != null) entry.exit(); // 异步完成后再释放 }); } catch (BlockException e) { // 异步任务本身被限流 }注意AsyncEntry的exit()不能在提交异步任务的线程里立刻执行,而要等异步任务真正完成后。否则又会出现统计提前结束的问题。
3.5 坑五:把 Entry 对象共享给其他线程用
还有一个不多见但很致命的错误:有人为了省事,把Entry对象存到一个静态变量里,其他线程直接引用。这是绝对禁止的。Entry和线程上下文强相关,跨线程共享会直接破坏统计链路。如果要在异步场景传递,应该传Context,而不是传Entry。
4. 把编程式埋点玩出花:参数透传、热点规则、自定义 Slot
4.1 按参数限流:让规则认识业务维度
SphU.entry()的第四个参数args不是摆设,它是热点参数限流的入口。比如一个查询用户信息的接口,大部分用户 QPS 不高,但某个头部大客户可能瞬间涌来大量请求。如果只按资源名限流,要么阈值设太高,普通用户被打爆;要么阈值设太低,大客户被频繁限流。
解决办法是在SphU.entry()时把用户 ID 透传进去:
Entry entry = null; try { entry = SphU.entry("queryUserInfo", EntryType.IN, 1, userId); // 业务逻辑 } catch (BlockException e) { // 兜底 } finally { if (entry != null) entry.exit(); }然后配置热点参数限流规则:
ParamFlowRule rule = new ParamFlowRule("queryUserInfo") .setParamIdx(0) // 针对第一个参数(userId) .setCount(100); // 每个参数值每秒最多 100 个请求 ParamFlowRuleManager.loadRules(Collections.singletonList(rule));这样普通用户的限制是每秒 100 个请求;需要给大客户单独放行,可以配置参数例外项setParamFlowItemList,给特定 userId 更大的阈值。这个能力在直接使用SphU.entry()时是最直观的,注解方式虽然也能配置args,但需要额外借助@SentinelResource的method和参数解析,绕了一道。
4.2 预热式(Warm Up)与编程式埋点的配合
有一个高频词是"预热式",在 Sentinel 语境里对应的是流控效果中的 Warm Up 预热模式。它的设计思想是:系统刚启动时,处理能力还不是满血状态,如果一开始就放满流量,很可能直接把服务打崩。所以预热模式允许流量在指定时间窗口内从一个小阈值缓慢增长到设定阈值。
FlowRule中可以这样配置:
FlowRule rule = new FlowRule("createOrder"); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setCount(1000); // 稳定后的阈值 rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP); rule.setWarmUpPeriodSec(30); // 预热 30 秒这个预热效果和编程式埋点是天然搭配的:只要资源名用的是SphU.entry()里定义的那个名字,规则会自动生效。它跟你用不用注解没有关系。也就是说,编程式埋点不仅不影响使用 Warm Up,反而因为资源命名更灵活,能针对不同资源做不同的预热度。
4.3 自定义 ProcessorSlot:当内置 slot 不够用的时候
前面的内容都在说怎么用 Sentinel 已有的能力。但编程式埋点做到后面,你会发现有些诉求内置 slot 覆盖不了。比如我想统计每一次调用来自哪个调用方、走了哪条 RPC 链路;或者想对某个资源单独做业务维度的计数。这时候可以考虑自定义ProcessorSlot。
Sentinel 的设计是典型的责任链模式:SphU.entry()触发后,请求会依次经过NodeSelectorSlot、ClusterBuilderSlot、StatisticSlot、FlowSlot、DegradeSlot等节点。每个 slot 做一件独立的事。自定义 slot 只需要实现ProcessorSlot接口,或者继承AbstractLinkedProcessorSlot,然后在entry()和exit()方法里加自己的逻辑。
举个例子,我想给资源启动时打印一条日志:
public class MyLogSlot extends AbstractLinkedProcessorSlot<DefaultNode> { @Override public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable { System.out.println("进入资源:" + resourceWrapper.getName()); fireEntry(context, resourceWrapper, node, count, prioritized, args); } @Override public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) { System.out.println("退出资源:" + resourceWrapper.getName()); fireExit(context, resourceWrapper, count, args); } }注册时通过 SPI 机制替换或补充SlotChainBuilder,把自定义 slot 插入到链路的合适位置。这些业务无关、样板性质的逻辑,很适合用 slot 扩展点统一处理,而不是散落在每个业务代码里。
5. 选型建议:手动编程埋点还是 @SentinelResource 注解
5.1 一张表对照
| 维度 | 编程式埋点(SphU.entry) | 注解式埋点(@SentinelResource) |
|---|---|---|
| 适用环境 | 任何 Java 环境 | 主要面向 Spring 容器 |
| 资源动态命名 | 完美支持,运行时拼接 | 不灵活,只能写静态值 |
| 代码侵入性 | 高,业务代码里要手动写 | 低,注解加在方法上即可 |
| 控制粒度 | 细到既能控制入口,也能控制出口 | 以方法调用为粒度 |
| 异步场景 | 支持 AsyncEntry,但需要手动管理 | 依赖切面,实现起来麻烦 |
| 学习成本 | 较高,要理解 Entry/Context | 较低,配置简单 |
| 日志可观测性 | 天然更透明,调试直观 | 底层也是 SphU,需要自己 Hook |
5.2 我的个人建议
综合多年的使用经验,我的选型原则是:凡是对外暴露的网关接口、中间件调用链、循环体、动态参数这类场景,一律用编程式埋点;凡是 Spring 容器内的业务 Service 方法,优先用@SentinelResource注解,省事且代码更干净。
另外,编程式埋点非常推荐做一层封装。不要每处都裸调SphU.entry(),否则资源名拼写不一致、exit 遗漏的问题会被无限放大。我通常会在项目里写一个工具类,例如:
public final class SentinelUtil { private SentinelUtil() {} public static Entry entry(String resourceName, Object... args) { return SphU.entry(resourceName, EntryType.IN, 1, args); } public static Entry entryOut(String resourceName, Object... args) { return SphU.entry(resourceName, EntryType.OUT, 1, args); } public static void exitFinally(Entry entry) { if (entry != null) { entry.exit(); } } }使用时的统一范式:
Entry entry = null; try { entry = SentinelUtil.entry("createOrder", userId); // ... } catch (BlockException ex) { // ... } catch (Exception e) { Tracer.trace(e); throw e; } finally { SentinelUtil.exitFinally(entry); }这样即使团队里有新手,也不太容易把entry释放逻辑写错。
5.3 一个小技巧:资源名加统一前缀,给排查留后路
最后分享一个实操技巧:给资源名加统一前缀。我习惯用"biz:"、"rpc:"、"cache:"这样的命名空间来区分业务来源。比如第三方 HTTP 调用:"http:queryScore";缓存:"cache:userInfo"。这样做有两个好处:控制台的资源列表一目了然;后续做规则批量推送、监控大盘分组时,可以按前缀过滤。
如果资源名里还需要拼动态 ID,建议把它放在后面,形如"queryData:tenantA"。因为 Sentinel 控制台展示资源时会按字符串排序,前缀固定的话,同一类资源的展示位置是挨在一起的。等规则出问题要排查时,这个命名习惯能帮你省下不少时间。
编程式埋点这件事,代码量并不大,但它对 Sentinel 的理解要求是全方位的。花点时间把entry、exit、Context、AsyncEntry这套机制摸透,后面再遇到任何限流降级的问题,你都能从底层逻辑去推导,而不是靠猜。这也是我从"注解一把梭"转到"手动埋点也敢写"之后最深刻的感受。