sentinel自定义slot实现对于指定参数的直接熔断
随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 是面向分布式、多语言异构化服务架构的流量治理组件,主要以流量为切入点,从流量路由、流量控制、流量整形、熔断降级、系统自适应过载保护、热点流量防护等多个维度来帮助开发者保障微服务的稳定性,由阿里巴巴开源。
sentinel学习地址:介绍 · alibaba/Sentinel Wiki (github.com)
sentinel中文官网:quick-start | Sentinel (sentinelguard.io)
本例代码地址:yealike/SentinelParamDegrade (github.com)
1.背景
现在有这么一个场景,在微服务架构中,系统间的相互调用是不可避免的,然而在业务高峰期,我的系统自身已经承担了很多压力,这时候我不希望再有那么多的第三方来调用我。这时候该怎么办呢?
注:在第三方调用我的时候,业务方会传入一个唯一的用户id。我根据这个业务id来判断这个第三方是否是经过我授权的,如果业务id识别正确,那么我就放行,允许他来访问我的接口,如果不正确那么不好意思直接熔断拒绝服务。
说到这,大家心里可能大概了解了,对于一下不重要的第三方(比如说给钱少的)请求到达的时候,我只允许他在规定的时候内调用指定次数。例如给他限定一个QPS为5的调用量,当他的调用量用完之后,将会进入熔断期,比如10秒,这10秒内的请求都会被拒绝,10后重新开放5个QPS给他调用。这样就能达到了我对自身系统的保护。
要说熔断限流的工具,笔者知识范围有限,只知道一个sentinel
然而查阅官方文档,sentinel并没有这种限流方式,只有一个热点参数限流与我想要的功能接近,但是我要的是直接熔断而不是限流。
限流与熔断,所以只能扩展源码。
2.限流与熔断的区别
举例,一个接口有两个参数id和name,我对id=5这个参数设置熔断和限流的阈值QPS为5
@Operation(method = GET_METHOD, summary = "index")
@GetMapping(path = {"/index"})
public String index(int id, String name) {
return "welcome id=" + id + "name" + name;
}
热点参数限流:当请求QPS到达阈值5的时候,触发限流条件,抛出一次异常,下一个时间窗口到达时重新计算流控触发条件
指定参数熔断:当请求QPS达到阈值5的时候,触发熔断机制,熔断器开始工作,在定义熔断条件时,会设置进入熔断保护的时间(比如30秒),那么在未来的30秒内,如果再次传入id=5这个参数请求这个接口时,会持续抛出异常,直到熔断时间结束,解除保护。然而当熔断期内利用id=6这个参数请求该接口时,接口可以正常返回数据给该线程,当id=5时抛出异常,拒绝提供服务。
3.实现原理
请直接将我github上的代码clone到本地,参照代码理解更加方便。yealike/SentinelParamDegrade (github.com)
在数据统计部分,借鉴Sentinel原生的热点参数熔断的代码。
在sentinel源码中热点参数限流的代码位于sentinel-extension模块下的sentinel-parameter-flow-control子模块,有兴趣的读者可以研究一下。
1)新建maven项目引入依赖
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
<version>1.8.6</version>
</dependency>
<!-- 其实不引入下面这个依赖也行,下面的包里的代码只是作为参考 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-parameter-flow-control</artifactId>
<version>1.8.6</version>
</dependency>
2)新建slot
在 Sentinel 里面,所有的资源都对应一个资源名称以及一个 Entry。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 API 显式创建;每一个 Entry 创建的时候,同时也会创建一系列功能插槽(slot chain)。这些插槽有不同的职责。
Sentinel 将 ProcessorSlot
作为 SPI 接口进行扩展(1.7.2 版本以前 SlotChainBuilder
作为 SPI),使得 Slot Chain 具备了扩展的能力。您可以自行加入自定义的 slot 并编排 slot 间的顺序,从而可以给 Sentinel 添加自定义的功能。
在maven的resource目录下新建META-INF/services两级目录
在该目录下新建文件,文件名为
com.alibaba.csp.sentinel.slotchain.ProcessorSlot
文件的内容是我们自定义的slot的全限定名。此处主要是利用java的spi机制动态加载插件,感兴趣的小伙伴可以研究一下java的SPI机制。Sentinel 主要是利用职责链的设计模式将一系列slot加入到他的职责链中,我们自定义的slot只要符合规范也可以加入其中,注意调整优先级即可,关于自定义的slotParamCircuitBreakerSlot在下面会有详细解释。
3)拷贝源码
既然sentinel已经提供了数据统计的能力,我就不再重复的造轮子了,直接拷贝源码
将sentinel-parameter-flow-control
模块下的如下几个类拷贝到我们自己的项目中
ParameterMetric
ParameterMetricStorage
ParamFlowArgument
ParamFlowChecker
ParamFlowClusterConfig
ParamFlowItem
ParamFlowException
ParamFlowRule
ParamFlowRuleManager
ParamFlowRuleUtil
我的项目中将这些类进行了重命名,并且将不同功能的类放入了不同的怕package下,目的是防止产生歧义和方便理解
4)改造源码
源码有如下几个地方需要改造
1.ParamDegradeRule
类中新增一个参数为recoveryTimeoutMs
含义为熔断器的恢复时间
2.我在我的unit包下定义了两个类,用来封装熔断参数的单位。分别为BreakTime
和ParamDegradeWrapper
。其中ParamDegradeWrapper
需要重写hashCode
和equals
方法,用来封装唯一的熔断参数。
ParamDegradeWrapper
:用于封装资源名称,资源名称是唯一的。BreakTime
:用于封装熔断时间和恢复时间
3.在自定义的slot类ParamCircuitBreakerSlot
是我们本次改造的重点,首先在类上添加@Spi(order = -1400)
将我们自定义的熔断器加入到sentinel的职责链中。
该类继承AbstractLinkedProcessorSlot需要重写两个方法entry和exit分别对应入口流控和出口流控规则
出口流控规则比较简单,我们不对出口流量做限制,直接将请求放行到下一个职责链,代码如下
// 出口流控
@Override
public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
fireExit(context,resourceWrapper,count,args);
}
5)ParamCircuitBreakerSlot详解
入口流控规则比较丰富,自定义的slot ParamCircuitBreakerSlot代码如下
@Spi(order = -1400)
public class ParamCircuitBreakerSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
private static ConcurrentHashMap<ParamDegradeWrapper, BreakTime> circuitBreaker = new ConcurrentHashMap<>();
// 入口流控
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable {
// 判断是否触发降级
// 如果没有配置改熔断规则,直接放行进入下一个slot
if (!ParamDegradeRuleManager.hasRules(resourceWrapper.getName())) {
fireEntry(context, resourceWrapper, node, count, prioritized, args);
return;
}
performChecking(resourceWrapper, count, args);
// 进入下一个slot
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
private void performChecking(ResourceWrapper resourceWrapper, int count, Object[] args) throws ParamDegradeException {
if (args == null) {
return;
}
if (!ParamDegradeRuleManager.hasRules(resourceWrapper.getName())) {
return;
}
// 获取所有规则
List<ParamDegradeRule> rules = ParamDegradeRuleManager.getRulesOfResource(resourceWrapper.getName());
for (ParamDegradeRule rule : rules) {
applyRealParamIdx(rule, args.length);
// 判断当前rule下是否有熔断器
if (!circuitBreaker.isEmpty() && args.length > 0) {
checkCircuitBreaker(resourceWrapper, rule, rule.getParamDegradeItemList(), args);
}
ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);
// 如果不满足某个rule 则抛出异常,代表当前请求被限流
if (!ParamDegradeChecker.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);
}
// 打开熔断器,指定参数被熔断
List<ParamDegradeItem> items = rule.getParamDegradeItemList();
for (ParamDegradeItem item : items) {
ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), rule.getParamIdx(), triggeredParam, item.getClassType());
circuitBreaker.put(wrapper, new BreakTime(System.currentTimeMillis(), rule.getRecoveryTimeoutMs()));
}
throw new ParamDegradeException(resourceWrapper.getName(), triggeredParam, rule);
}
}
}
private void checkCircuitBreaker(ResourceWrapper resourceWrapper, ParamDegradeRule rule, List<ParamDegradeItem> items, Object[] args) throws ParamDegradeException {
if (!items.isEmpty()) {
for (ParamDegradeItem item : items) {
Integer paramIdx = rule.getParamIdx();
if (paramIdx >= args.length) {
throw new IllegalArgumentException("参数长度有误,需要熔断的参数idx超过传入的参数长度");
}
// 拿出当前请求的参数
String currentArg = String.valueOf(args[paramIdx]);
ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), paramIdx, currentArg, item.getClassType());
BreakTime breakTime = circuitBreaker.get(wrapper);
// 当前对象取值不为空,且当前参数在熔断范围内,执行熔断
if (breakTime != null && wrapper.getValue().equals(currentArg)) {
if (breakTime.getStartTime() + breakTime.getDuration() > System.currentTimeMillis()) {
throw new ParamDegradeException(resourceWrapper.getName(), item.getObject(), rule);
} else {
circuitBreaker.remove(wrapper);
}
}
}
}
}
// paramIdx允许为负数,倒数第几个参数,将其转换为正数
void applyRealParamIdx(/*@NonNull*/ ParamDegradeRule 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);
}
}
}
// 出口流控
@Override
public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
fireExit(context,resourceWrapper,count,args);
}
}
@Spi(order = -1400)
该slot的加载顺序在DegradeSlot
之前DefaultCircuitBreakerSlot
之后。
注:笔者参考的源码是sentinel截至目前最新的master分支的代码,而实际引用的是sentinel1.8.6的代码,所以1.8.6的源码中可能找不到DefaultCircuitBreakerSlot
,不过影响我们自定义slot的改造。
在入口流控规则中主要有三件事
- 判断是否配置了降级规则,如果没有配置降级规则,直接放行进入下一个slot
- 配置降级规则之后,判断是否触发降级条件
- 进入下一个slot
对其中的第二条进行详解
我的代码中将判断是否触发降级条件封装成一个方法performChecking(resourceWrapper, count, args);
在讲performChecking方法之前,先介绍一下自定义的熔断器,是一个Map集合。自定义的熔断器与sentinel官方提供的根据异常数/异常比例、慢请求的熔断器有很大区别。官方的熔断器有open、half-open、close这三种状态,并且在熔断器工作的时间内还会涉及到这三种状态相互转化的逻辑。
自定义的熔断器是一个Map集合,很简单,没有任何状态,如果集合内有元素就说明有资源需要熔断,否则没有。
private static ConcurrentHashMap<ParamDegradeWrapper, BreakTime> circuitBreaker
= new ConcurrentHashMap<>();
定义一个ConcurrentHashMap
用来存储每个资源对应的熔断规则,其中的泛型为自定义的两个类,ParamDegradeWrapper
用来封装资源名(为了确保唯一性,需要重写hashCode和equals方法),BreakTime
用来封装熔断开始时间和熔断时长。
下面看一下performChecking
的代码。
private void performChecking(ResourceWrapper resourceWrapper, int count, Object[] args) throws ParamDegradeException {
if (args == null) {
return;
}
if (!ParamDegradeRuleManager.hasRules(resourceWrapper.getName())) {
return;
}
// 获取所有规则
List<ParamDegradeRule> rules = ParamDegradeRuleManager.getRulesOfResource(resourceWrapper.getName());
for (ParamDegradeRule rule : rules) {
applyRealParamIdx(rule, args.length);
// 判断当前rule下是否有熔断器
if (!circuitBreaker.isEmpty() && args.length > 0) {
checkCircuitBreaker(resourceWrapper, rule, rule.getParamDegradeItemList(), args);
}
ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);
// 如果不满足某个rule 则抛出异常,代表当前请求被限流
if (!ParamDegradeChecker.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);
}
// 打开熔断器,指定参数被熔断
List<ParamDegradeItem> items = rule.getParamDegradeItemList();
for (ParamDegradeItem item : items) {
ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), rule.getParamIdx(), triggeredParam, item.getClassType());
circuitBreaker.put(wrapper, new BreakTime(System.currentTimeMillis(), rule.getRecoveryTimeoutMs()));
}
throw new ParamDegradeException(resourceWrapper.getName(), triggeredParam, rule);
}
}
}
这段代码中的主要内容在循环体内,ParamDegradeRuleManager.getRulesOfResource()
拿出所有的熔断规则之后,开始遍历其中的rules依次判断,检查哪个resource达到了熔断的条件,如果触发了熔断条件,则执行熔断逻辑。
在检查是否触发熔断条件之前,有一个条件语句,用来判断当前的resource是否包含在熔断器内,
if (!circuitBreaker.isEmpty() && args.length > 0) {
checkCircuitBreaker(resourceWrapper, rule, rule.getParamDegradeItemList(), args);
}
如下代码属于核心逻辑
private void checkCircuitBreaker(ResourceWrapper resourceWrapper, ParamDegradeRule rule, List<ParamDegradeItem> items, Object[] args) throws ParamDegradeException {
if (!items.isEmpty()) {
for (ParamDegradeItem item : items) {
Integer paramIdx = rule.getParamIdx();
if (paramIdx >= args.length) {
throw new IllegalArgumentException("参数长度有误,需要熔断的参数idx超过传入的参数长度");
}
// 拿出当前请求的参数
String currentArg = String.valueOf(args[paramIdx]);
ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), paramIdx, currentArg, item.getClassType());
BreakTime breakTime = circuitBreaker.get(wrapper);
// 当前对象取值不为空,且当前参数在熔断范围内,执行熔断
if (breakTime != null && wrapper.getValue().equals(currentArg)) {
if (breakTime.getStartTime() + breakTime.getDuration() > System.currentTimeMillis()) {
throw new ParamDegradeException(resourceWrapper.getName(), item.getObject(), rule);
} else {
circuitBreaker.remove(wrapper);
}
}
}
}
}
代码非常精简
主要是将当前资源封装成自定的资源包装器ParamDegradeWrapper
,然后从熔断器的Map里面尝试获取元素,如果能够获取到元素,并且当前资源在熔断时间内,那么进行熔断操作,否则说明当前资源不需要熔断。从map集合中移除该元素。
熔断器看完之后,再回头看performChecking()
方法中的其他内容。接着往下看,有一个条件语句!ParamDegradeChecker.passCheck(resourceWrapper, rule, count, args)
的作用是判断当前资源是否达到的熔断条件,如果判断为true那么说明判断成功,达到熔断条件,此时就需要将当前资源封装成自定义的资源包装器ParamDegradeItem,并将该资源添加进自定义的熔断器集合circuitBreaker。同时拒绝本次访问,具体措施为抛出异常throw new ParamDegradeException(resourceWrapper.getName(), triggeredParam, rule);
至此,我们自定义的熔断器来进行指定参数熔断的功能就完成了,下面是对我们的熔断器进行测试
4.测试
测试方法完整代码如下
public class ParamDegradeTest {
private static final String RESOURCE_NAME = "paramResource";
public static void main(String[] args) {
paramDegrade();
ExecutorService executor = Executors.newFixedThreadPool(2);
// 创建第一个线程
Runnable task1 = () -> {
while (true) {
handleRequest("paramA");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
// 创建第二个线程
Runnable task2 = () -> {
while (true) {
handleRequest("paramB");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
// 提交任务到线程池
executor.execute(task1);
executor.execute(task2);
// 关闭线程池,这将使得之前提交的任务执行完毕后,线程池关闭
executor.shutdown();
}
private static void paramDegrade() {
ParamDegradeRule rule = new ParamDegradeRule(RESOURCE_NAME)
.setParamIdx(0)
.setRecoveryTimeoutMs(3000)
.setCount(5);
// 针对 int 类型的参数 PARAM_B,单独设置限流 QPS 阈值为 10,而不是全局的阈值 5.
ParamDegradeItem item = new ParamDegradeItem().setObject("paramA")
.setClassType(String.class.getName())
.setCount(2);
rule.setParamDegradeItemList(Collections.singletonList(item));
ParamDegradeRuleManager.loadRules(Collections.singletonList(rule));
}
private static void handleRequest(String param) {
ContextUtil.enter(RESOURCE_NAME);
Entry entry = null;
try {
SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param);
// 业务代码
System.out.println("正常" + param + "==>" + "时间" + System.currentTimeMillis());
} catch (BlockException ex) {
System.out.println("熔断: " + param + "==>" + System.currentTimeMillis());
} finally {
if (entry != null) {
entry.exit(1, param);
}
}
}
}
先看main方法
主方法中主要有两件事
1.定义熔断规则
paramDegrade();
2.开辟线程池,定义两个线程,同时执行一段相同的业务代码,这两个线程执行的业务代码逻辑相同,唯一的不同点是所携带的参数内容不同,分别为paramA和paramB并且执行频率为300ms一次(即QPS为3左右)
先看下熔断规则是如何定义的,代码如下
private static void paramDegrade() {
ParamDegradeRule rule = new ParamDegradeRule(RESOURCE_NAME)
.setParamIdx(0)
.setRecoveryTimeoutMs(3000)
.setCount(5);
// 针对 int 类型的参数 PARAM_B,单独设置限流 QPS 阈值为 10,而不是全局的阈值 5.
ParamDegradeItem item = new ParamDegradeItem().setObject("paramA")
.setClassType(String.class.getName())
.setCount(2);
rule.setParamDegradeItemList(Collections.singletonList(item));
ParamDegradeRuleManager.loadRules(Collections.singletonList(rule));
}
下面我将对参数进行详细讲解
ParamDegradeRule
相当于全局配置,如果没有对指定的某个参数配置规则,那么当请求QPS到达5之后,无论请求的是什么参数都将会熔断。
ParamDegradeItem
相当于配置全局参数的例外参数,该参数当请求QPS到达2的时候就执行了熔断操作,熔断条件更为苛刻。
ParamDegradeRule
有一个构造函数,参数名是自定义的资源名,由于在业务代码中也用到了资源名。所以将资源名抽取成全局变量private static final String RESOURCE_NAME = "paramResource";
setParamIdx
是指定第几个参数进行熔断,下标从0开始。本例中我们熔断地的是第一个参数。
setRecoveryTimeoutMs
是当前参数的熔断时长为多少,单位为毫秒,本例中我们的熔断时长为3秒,3秒后解除熔断保护。
setCount
是值需要触发熔断的保护条件阈值为多少,本例中的条件为QPS为5(也可以拓展为线程数,读者自行拓展)。
ParamDegradeItem
设置例外参数
setObject("paramA")
:对paramA这个参数做单独保护
setClassType(String.class.getName())
:paramA的参数类型为String
setCount(2)
:paramA触发熔断保护的QPS为2
规则讲解完毕之后我们看业务代码
业务代码在两个线程中执行,其中一个线程代码如下,另一个线程除了传入参数不同,其他逻辑没什么不同
Runnable task1 = () -> {
while (true) {
handleRequest("paramA");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
业务代码handleRequest("paramA");
的方法体如下
private static void handleRequest(String param) {
ContextUtil.enter(RESOURCE_NAME);
Entry entry = null;
try {
SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param);
// 业务代码
System.out.println("正常" + param + "==>" + "时间" + System.currentTimeMillis());
} catch (BlockException ex) {
System.out.println("熔断: " + param + "==>" + System.currentTimeMillis());
} finally {
if (entry != null) {
entry.exit(1, param);
}
}
}
业务代码中主要通过SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param);
来执行。对这行代码不熟悉的小伙伴直接求助于chatgpt
SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param);
的含义:
SphU.entry()
:这是 Sentinel 提供的 API,用于标记一个资源开始访问,它会返回一个Entry
对象。如果当前访问被限制(例如超出了流量限制),则会抛出BlockException
。RESOURCE_NAME
:资源名称,它是一个字符串,用于标识一个具体的资源。例如,你可能会为每一个 HTTP API 路径定义一个不同的资源名称。EntryType.IN
:标识流量的类型。在 Sentinel 中,主要有两种流量类型:IN
和OUT
。IN
通常表示进入的流量(例如请求进来的流量),而OUT
表示出去的流量(例如对外部服务的调用)。1
:表示一个请求。在大多数情况下,这个值都是 1,代表一个请求。但是在某些情境下,如批量请求,这个值可能会大于 1。param
:这是一个可选参数,用于传入一些附加的参数信息。这在某些高级的流量控制策略中可能会用到,例如当你需要基于参数来做流量控制时。
总的来说,这段代码的作用是标记一个资源开始被访问,并且这个访问是进入的流量,数量为 1,同时还传入了一些附加参数。如果当前资源的访问超出了设置的流量控制规则,那么 SphU.entry()
会抛出异常。
规则定义完成,业务代码check完成。执行main方法
观察控制台输出
从控制台输出观察到,paramA参数被熔断,paramB参数正常方法,请求直接被放行。计算一下paramA最后一次请求正常的时间和相邻的最后一次熔断熔断时间分别为1696920565241和1696920562114之差为3127ms
至此,改造与测试均完成