引言
在前面的文章中,我们已经简单地介绍了 Sentinel 的核心概念、核心功能以及实现的思想,从本篇文章开始,我将介绍一下如何使用 Sentinel 的核心功能,本文先介绍一下如何定义 Sentinel 中的资源。更多相关文章和其他文章均收录于贝贝猫的文章目录。
简介
Sentinel 可以简单的分为 Sentinel 核心库和 Dashboard。核心库不依赖 Dashboard,但是结合 Dashboard 可以取得最好的效果。这里所说的资源,可以是任何东西,服务,服务里的方法,甚至是一段代码。使用 Sentinel 来进行资源保护,主要分为几个步骤:
- 定义资源
- 定义规则
- 检验规则是否生效
先把可能需要保护的资源定义好(埋点),之后再配置规则。也可以理解为,只要有了资源,我们就可以在任何时候灵活地定义各种流量控制规则。在编码的时候,只需要考虑这个代码是否需要保护,如果需要保护,就将之定义为一个资源。此外,对于主流的框架,Sentinel 已经提供了适配。接下来,我将向大家介绍一下如果定义 Sentinel 中的资源,而针对不同资源的规则设定,我们会在后续的文章中介绍。
定义资源
主流框架的默认适配
为了减少开发的复杂程度,Sentinel 已经对大部分的主流框架,例如 Web Servlet、Dubbo、Spring Cloud、gRPC、Spring WebFlux、Reactor 等都做了适配。我们只需要引入对应的依赖即可方便地整合 Sentinel。
下面,我们以 Dubbo 的适配为例,介绍 Sentinel 提供的适配功能,在这里 Sentinel-dubbo-adapter 通过为 Service Provider 和 Service Consumer 实现的 Filter 来达到自动适配的功能。我们仅仅需要引入相关模块,Dubbo 的服务接口和方法(包括调用端和服务端)就会成为 Sentinel 中的资源,在配置了规则后就可以自动享受到 Sentinel 的防护能力。
对于 Apache Dubbo 2.7.x 及以上版本,使用时需引入以下模块(以 Maven 为例):
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-apache-dubbo-adapter</artifactId>
<version>x.y.z</version>
</dependency>
熟悉 Dubbo 的同学应该知道, 在 Dubbo 中 Filter 是通过 SPI 的方式接入的,即在 classpath 中的 /META-INF/dubbo
目录,通过接口全限定名和实现类全限定名来接入。这部分工作 dubbo 适配器已经帮我们做了,所以我们在引入 dubbo 适配器时,什么配置信息也不用写。
相较于 Dubbo 框架来说,其他框架想要接入 Sentinel 就显得稍微麻烦一点,没法做到 dubbo 这种全自动接入的效果。假如我们要使用 WebFlux 适配器的话,就不仅需要在项目中引入 webflux 适配器模块,还要自己完成相应的接入配置。
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-spring-webflux-adapter</artifactId>
<version>x.y.z</version>
</dependency>
@Configuration
public class WebFluxConfig {
private final List<ViewResolver> viewResolvers;
private final ServerCodecConfigurer serverCodecConfigurer;
public WebFluxConfig(ObjectProvider<List<ViewResolver>> viewResolversProvider,
ServerCodecConfigurer serverCodecConfigurer) {
this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
this.serverCodecConfigurer = serverCodecConfigurer;
}
@Bean
@Order(-1)
public SentinelBlockExceptionHandler sentinelBlockExceptionHandler() {
// Register the block exception handler for Spring WebFlux.
return new SentinelBlockExceptionHandler(viewResolvers, serverCodecConfigurer);
}
@Bean
@Order(-1)
public SentinelWebFluxFilter sentinelWebFluxFilter() {
// Register the Sentinel WebFlux filter.
return new SentinelWebFluxFilter();
}
}
抛出异常的方式
SphU 包含了 try-catch 风格的 API。用这种方式,当资源发生了限流之后会抛出 BlockException。这个时候可以捕捉异常,进行限流之后的逻辑处理。示例代码如下:
// 1.5.0 版本开始可以利用 try-with-resources 特性(使用有限制)
// 资源名可使用任意有业务语义的字符串,比如方法名、接口名或其它可唯一标识的字符串。
try (Entry entry = SphU.entry("resourceName")) {
// 被保护的业务逻辑
// do something here...
} catch (BlockException ex) {
// 资源访问阻止,被限流或被降级
// 在此处进行相应的处理操作
}
需要注意的是,上述这种 try-with-resources
方式的 API 只能在不使用其他 entry 接口参数,以及没有异常记录 Tracer.trace(ex)
时使用,这一方面是因为 Entry
实例在实现 AutoCloseable
接口只会简单的调用 Entry::exit()
(只会释放 1 个 token), 所以如果您在使用 entry 接口时,指定了非 1 token count 时,就会有统计错误的情况。
try (Entry entry = SphU.entry("resourceName", /*token count*/5)) {
// ...
} catch (BlockException ex) {
// ...
}
另一方面,因为 try-with-resources
的执行顺序是 try block -> close -> catch block -> finally block
,所以,如果在 catch 块中调用 Tracer.trace(ex) 时,会出现统计异常数出错的情况。这里我简单地介绍一下业务异常记录类 Tracer 的知识,它主要用于记录业务异常。首先,它的相关方法有:
trace(Throwable e)
:记录业务异常(非 BlockException 异常),对应的资源为当前线程 context 下 entry 对应的资源。该方法必须在 SphU.entry(xxx) 成功之后且 exit 之前调用,否则当前 context 为空则会抛出异常。trace(Throwable e, int count)
:记录业务异常(非 BlockException 异常),异常数目为传入的 count。该方法必须在 SphU.entry(xxx) 成功之后且 exit 之前调用,否则当前 context 为空则会抛出异常。traceEntry(Throwable, int, Entry)
:向传入 entry 对应的资源记录业务异常(非 BlockException 异常),异常数目为传入的 count。
如果用户通过 SphU 或 SphO 手动定义资源,则 Sentinel 不能感知上层业务的异常,需要手动调用 Tracer.trace(ex) 来记录业务异常,否则对应的异常不会统计到 Sentinel 异常计数中。从 1.4.0 版本开始,注解方式定义资源支持自动统计业务异常,无需手动调用 Tracer.trace(ex) 来记录业务异常。Sentinel 1.4.0 以前的版本需要手动记录。
综上所述,一个完备的 try-catch 风格的 API 调用方式如下所示:
Entry entry = null;
try {
// 若需要配置例外项,则传入的参数只支持基本类型。
// EntryType 代表流量类型,其中系统规则只对 IN 类型的埋点生效
// count 大多数情况都填 1,代表统计为一次调用。当然,我们也可以填大于 1 的值,表示一次需要多个 token
entry = SphU.entry(resourceName, EntryType.IN, /*token count*/5, paramA, paramB);
// Your logic here.
} catch (BlockException ex) {
// Handle request rejection.
// 资源访问阻止,被限流或被降级
// 进行相应的处理操作
} catch (Exception ex) {
// 若需要配置降级规则,需要通过这种方式记录业务异常
Tracer.traceEntry(ex, entry);
} finally {
// 注意:exit 的时候也一定要带上对应的参数,否则可能会有统计错误。
if (entry != null) {
entry.exit(/*token count*/5, paramA, paramB);
}
}
返回布尔值方式
SphO 提供 if-else 风格的 API。用这种方式,当资源发生了限流之后会返回 false,这个时候可以根据返回值,进行限流之后的逻辑处理。示例代码如下:
// 资源名可使用任意有业务语义的字符串
if (SphO.entry("自定义资源名")) {
// 务必保证finally会被执行
try {
/**
* 被保护的业务逻辑
*/
} finally {
SphO.exit();
}
} else {
// 资源访问阻止,被限流或被降级
// 进行相应的处理操作
}
注意:
SphO.entry(xxx)
需要与SphO.exit()
方法成对出现,匹配调用,位置正确,否则会导致调用链记录异常,抛出ErrorEntryFreeException
异常。
注解方式
Sentinel 还提供了 @SentinelResource
注解用于定义资源,并提供了 AspectJ 的扩展用于自动定义资源、处理 BlockException
等。使用 Sentinel Annotation AspectJ Extension 的时候需要引入以下依赖:
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-annotation-aspectj</artifactId>
<version>x.y.z</version>
</dependency>
如果您使用的是原生 Spring (无论是 Spring Boot 还是传统 Spring 应用), 而不是 Spring Cloud Alibaba 的话,您需要通过配置的方式将 SentinelResourceAspect 注册为一个 Spring Bean:
@Configuration
public class SentinelAspectConfiguration {
@Bean
public SentinelResourceAspect sentinelResourceAspect() {
return new SentinelResourceAspect();
}
}
@SentinelResource
可以用来定义资源,并提供可选的异常处理和 fallback 配置项。 该注解包含以下属性:
- value:资源名称,必需项(不能为空)
- entryType:entry 类型,可选项(默认为 EntryType.OUT)
- blockHandler / blockHandlerClass: blockHandler 对应处理 BlockException 的函数名称,可选项。blockHandler 函数访问范围需要是 public,返回类型需要与原方法相匹配,参数类型需要和原方法相匹配并且最后加一个额外的参数,类型为 BlockException。blockHandler 函数默认需要和原方法在同一个类中。若希望使用其他类的函数,则可以指定 blockHandlerClass 为对应的类的 Class 对象,注意对应的函数必需为 static 函数,否则无法解析。
- fallback / fallbackClass:fallback 函数名称,可选项,用于在抛出异常的时候提供 fallback 处理逻辑。fallback 函数可以针对所有类型的异常(除了 exceptionsToIgnore 里面排除掉的异常类型)进行处理。fallback 函数签名和位置要求:
- 返回值类型必须与原函数返回值类型一致;
- 方法参数列表需要和原函数一致,或者可以额外多一个 Throwable 类型的参数用于接收对应的异常。
- fallback 函数默认需要和原方法在同一个类中。若希望使用其他类的函数,则可以指定 fallbackClass 为对应的类的 Class 对象,注意对应的函数必需为 static 函数,否则无法解析。
- defaultFallback(since 1.6.0):默认的 fallback 函数名称,可选项,通常用于通用的 fallback 逻辑(即可以用于很多服务或方法)。默认 fallback 函数可以针对所有类型的异常(除了 exceptionsToIgnore 里面排除掉的异常类型)进行处理。若同时配置了 fallback 和 defaultFallback,则只有 fallback 会生效。defaultFallback 函数签名要求:
- 返回值类型必须与原函数返回值类型一致;
- 方法参数列表需要为空,或者可以额外多一个 Throwable 类型的参数用于接收对应的异常。
- defaultFallback 函数默认需要和原方法在同一个类中。若希望使用其他类的函数,则可以指定 fallbackClass 为对应的类的 Class 对象,注意对应的函数必需为 static 函数,否则无法解析。
- exceptionsToIgnore(since 1.6.0):用于指定哪些异常被排除掉,不会计入异常统计中,也不会进入 fallback 逻辑中,而是会原样抛出。
注意:注解方式埋点不支持 private 方法。
1.6.0 之前的版本 fallback 函数只针对降级异常(DegradeException)进行处理,不能针对业务异常进行处理。
若 blockHandler 和 fallback 都进行了配置,则被限流降级而抛出 BlockException 时只会进入 blockHandler 处理逻辑。若未配置 blockHandler、fallback 和 defaultFallback,则被限流降级时会将 BlockException 直接抛出(若方法本身未定义 throws BlockException 则会被 JVM 包装一层 UndeclaredThrowableException)。
从 1.4.0 版本开始,注解方式定义资源支持自动统计业务异常,无需手动调用 Tracer.trace(ex) 来记录业务异常。Sentinel 1.4.0 以前的版本需要自行调用 Tracer.trace(ex) 来记录业务异常。
示例:
public class TestService {
// 对应的 `handleException` 函数需要位于 `ExceptionUtil` 类中,并且必须为 static 函数.
@SentinelResource(value = "test", blockHandler = "handleException", blockHandlerClass = {ExceptionUtil.class})
public void test() {
System.out.println("Test");
}
// 原函数
@SentinelResource(value = "hello", blockHandler = "exceptionHandler", fallback = "helloFallback")
public String hello(long s) {
return String.format("Hello at %d", s);
}
// Fallback 函数,函数签名与原函数一致或加一个 Throwable 类型的参数.
public String helloFallback(long s) {
return String.format("Halooooo %d", s);
}
// Block 异常处理函数,参数最后多一个 BlockException,其余与原函数一致.
public String exceptionHandler(long s, BlockException ex) {
// Do some log here.
ex.printStackTrace();
return "Oops, error occurred at " + s;
}
}
异步调用支持
Sentinel 还支持异步调用链路的统计。在异步调用中,需要通过 SphU.asyncEntry(xxx)
方法定义资源,并通常需要在异步的回调函数中调用 exit
方法。以下是一个简单的示例:
try {
AsyncEntry entry = SphU.asyncEntry("asyncResource");
// 异步调用
doAsync(userId, result -> {
try {
// 在此处理异步调用结果
} finally {
// 在回调结束后 exit.
entry.exit();
}
});
} catch (BlockException ex) {
// Request blocked.
// Handle the exception (e.g. retry or fallback).
}
private void doAsync(final String arg, final Consumer<String> handler) {
threadPool.submit(new Runnable() {
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(1000);
String resp = arg + ": " + System.currentTimeMillis();
handler.accept(resp);
} catch (Exception ex) {
ex.printStackTrace();
}
}
});
}
还有一点需要注意的是 SphU.asyncEntry(xxx)
不会影响当前(调用线程)的 Context,因此下面的例子中,两个 entry 在调用链上是平级关系(处于同一层),而不是嵌套关系:
// 调用链类似于:
// -parent
// ---asyncResource
// ---syncResource
asyncEntry = SphU.asyncEntry(asyncResource);
entry = SphU.entry(normalResource);
若在异步回调中需要嵌套其它的资源调用(无论是 entry 还是 asyncEntry),只需要借助 Sentinel 提供的上下文切换功能,在对应的地方通过 ContextUtil.runOnContext(context, f) 进行 Context 变换,将对应资源调用处的 Context 切换为生成的异步 Context,即可维持正确的调用链路关系。示例如下:
public void someAsync() {
try {
AsyncEntry entry = SphU.asyncEntry(resourceName);
// Asynchronous invocation.
doAsync(userId, result -> {
// 在异步回调中进行上下文变换,通过 AsyncEntry 的 getAsyncContext 方法获取异步 Context
ContextUtil.runOnContext(entry.getAsyncContext(), () -> {
try {
// 此处嵌套正常的资源调用.
handleResult(result);
} finally {
entry.exit();
}
});
});
} catch (BlockException ex) {
// Request blocked.
// Handle the exception (e.g. retry or fallback).
}
}
public void handleResult(String result) {
Entry entry = null;
try {
entry = SphU.entry("handleResultForAsync");
// Handle your result here.
} catch (BlockException ex) {
// Blocked for the result handler.
} finally {
if (entry != null) {
entry.exit();
}
}
}
此时的调用链就类似于:
-parent
---asyncInvocation
-----handleResultForAsync
Context 介绍
既然前面提到了 Context,我们这再拓展性地介绍一下 Context。在 Sentinel 中我们通过 Context 标识进入调用链入口(上下文),以下静态方法都可以用来标识调用链路入口,区分不同的调用链路:
//该方法用于进入调用链,同时我们需要指定调用链路入口名称(上下文名称),返回值类型为 Context,即生成的调用链路上下文对象
public static Context enter(String contextName)
//该方法用于进入调用链,同时我们需要指定调用链路入口名称(上下文名称)以及调用来源名称,返回值类型为 Context,即生成的调用链路上下文对象
public static Context enter(String contextName, String origin)
//该方法用于退出调用链,清理当前线程的上下文。
public static void exit()
//获取当前线程的调用链路上下文对象。
public static Context getContext()
//常用于异步调用链路中 context 的变换。
public static void runOnContext(Context context, Runnable f)
那么,这个所谓的调用链入口有什么用呢?当我们在流控规则中将“流控模式”选择为“链路模式”时,实际的流控过程会将之前调用 SphU.entry
时指定的 resourceName 和上述的 contextName 联合在一起作为流量的区分条件,而如果“流控模式”选择为“直接模式”时,只会以调用 SphU.entry
时指定的 resourceName 作为流量的区分条件。这么说可能有点抽象,您可以简单的理解为:
- “流控模式”选择为“链路模式”时: Sentinel 内部会用一个 Map 来维护流量信息,Map 的 key 是
resourceName + contextName
, value 是流量统计信息 - “流控模式”选择为“直接模式”时: Sentinel 内部会用一个 Map 来维护流量信息,Map 的 key 是
resourceName
, value 是流量统计信息
这里,我简单说一下 contextName 可以用在什么地方,本质上它是在同一个 resource 上增加了一个区分度(调用链入口),所以一个简单的场景是: 我们有一个内部服务资源 ServiceFooResource
,它本身有一定的流量控制规则, 比如 QPS 1000,这个内部服务可能既开放了 HTTP 的访问方式,也开放了 RPC 的访问方式,我们可以在 HTTP 访问时将 contextName 设为 HTTP
,而在 RPC 访问时将 Context 设为 RPC
,这样我们就可以更加细化地设定 HTTP 形式访问的流量控制规则,以及 RPC 形式访问的流量控制规则。
ServiceFooResource: QPS 1000
--HTTP: QPS 200
--RPC: QPS 800
这里大家可能还有一个疑问:那么上述 enter(String contextName, String origin)
接口中的 origin 参数有什么用呢?它目前主要是用来统计同一 resource 下不同源的流量信息,通俗地讲,Sentinel 内部会用一个 Map 来维护流量信息,Map 的 key 是 resourceName + origin
, value 是流量统计信息。在 Sentinel 中可以对同一 resource 的不同 origin 设定流控规则,而且您也可以在授权规则中针对 orgin 的不同来设定黑名单或者白名单。
最后,关于 Context 的使用,这里还有一些小提醒:
ContextUtil.enter(xxx)
方法仅在调用链路入口处生效,即仅在当前线程的初次调用生效,后面再调用不会覆盖当前线程的调用链路,直到 exit- Context 存储于 ThreadLocal 中,因此切换线程时可能会丢掉,如果需要跨线程使用可以结合 runOnContext 方法使用
- origin 数量不要太多,否则内存占用会比较大。
参考内容
[1] Sentinel GitHub 仓库
[2] Sentinel 官方 Wiki
[3] Sentinel 1.6.0 网关流控新特性介绍
[4] Sentinel 微服务流控降级实践
[5] Sentinel 1.7.0 新特性展望
[6] Sentinel 为 Dubbo 服务保驾护航
[7] 在生产环境中使用 Sentinel
[8] Sentinel 与 Hystrix 的对比
[9] 大流量下的服务质量治理 Dubbo Sentinel初涉
[10] Alibaba Sentinel RESTful 接口流控处理优化
[11] 阿里 Sentinel 源码解析
[12] Sentinel 教程 by 逅弈
[13] Sentinel 专题文章 by 一滴水的坚持