SpringCloud进阶(3)–Sentinel流量防卫兵

在微服务中存在雪崩现象,也就是说如果一个微服务出现问题,可能会导致整个链路上的服务都直接不可用,因此,我们需要对服务进行及时的熔断和降级。

SpringCloud Alibaba也有自己的微服务容错组件:Sentinel

Sentinel 具有以下特征:

  • 丰富的应用场景:Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、集群流量控制、实时熔断下游不可用应用等。
  • 完备的实时监控:Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。
  • 广泛的开源生态:Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Apache Dubbo、gRPC、Quarkus 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。同时 Sentinel 提供 Java/Go/C++ 等多语言的原生实现。
  • 完善的 SPI 扩展机制:Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快速地定制逻辑。例如定制规则管理、适配动态数据源等。

Sentinel使用

如同Nacos一样,Sentinel也是独立安装和部署的,我们需要先去下载release

这是个SpringBoot项目,我们默认端口8080,账号密码都是 sentinel

我们运行后就算成功开启监控页面了,这样我们就可以让服务连接到Sentinel控制台,导入依赖:

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

配置文件添加Sentinel服务信息:

spring:
  application:
    name: userservice
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    sentinel:
      transport:
      	# 添加监控页面地址即可
        dashboard: localhost:8858

这样当我们运行服务并进行一次访问(因为是懒加载机制,需要一次访问才能加载),就能在Sentinel里看到信息。

流量控制

我们的机器不可能无限制的接受和处理客户端的请求,如果不加以限制,当发生高并发情况时,系统资源将很快被耗尽。为了避免这种情况,我们就可以添加流量控制(也可以说是限流)当一段时间内的流量到达一定的阈值的时候,新的请求将不再进行处理,这样不仅可以合理地应对高并发请求,同时也能在一定程度上保护服务器不受到外界的恶意攻击。

那么要实现限流,正常情况下,我们该采取什么样的策略呢?

  • 方案一:快速拒绝,既然不再接受新的请求,那么我们可以直接返回一个拒绝信息,告诉用户访问频率过高。
  • 方案二:预热,依然基于方案一,但是由于某些情况下高并发请求是在某一时刻突然到来,我们可以缓慢地将阈值提高到指定阈值,形成一个缓冲保护。
  • 方案三:排队等待,不接受新的请求,但是也不直接拒绝,而是进队列先等一下,如果规定时间内能够执行,那么就执行,要是超时就算了。

针对于是否超过流量阈值的判断,这里我们提4种算法:

1.漏桶算法

顾名思义,就像一个桶开了一个小孔,水流进桶中的速度肯定是远大于水流出桶的速度的,这也是最简单的一种限流思路:

我们知道,桶是有容量的,所以当桶的容量已满时,就装不下水了,这时就只有丢弃请求了。

利用这种思想,我们就可以写出一个简单的限流算法。

2.令牌桶算法

只能说有点像信号量机制。现在有一个令牌桶,这个桶是专门存放令牌的,每隔一段时间就向桶中丢入一个令牌(速度由我们指定)当新的请求到达时,将从桶中删除令牌,接着请求就可以通过并给到服务,但是如果桶中的令牌数量不足,那么不会删除令牌,而是让此数据包等待。

可以试想一下,当流量下降时,令牌桶中的令牌会逐渐积累,这样如果突然出现高并发,那么就能在短时间内拿到大量的令牌。

3.固定时间窗口算法

我们可以对某一个时间段内的请求进行统计和计数,比如在14:1514:16这一分钟内,请求量不能超过100,也就是一分钟之内不能超过100次请求,那么就可以像下面这样进行划分:

虽然这种模式看似比较合理,但是试想一下这种情况:

  • 14:15:59的时候来了100个请求
  • 14:16:01的时候又来了100个请求

出现上面这种情况,符合固定时间窗口算法的规则,所以这200个请求都能正常接受,但是,如果你反应比较快,应该发现了,我们其实希望的是60秒内只有100个请求,但是这种情况却是在3秒内出现了200个请求,很明显已经违背了我们的初衷。

因此,当遇到临界点时,固定时间窗口算法存在安全隐患。

4.滑动时间窗口算法

相对于固定窗口算法,滑动时间窗口算法更加灵活,它会动态移动窗口,重新进行计算:

虽然这样能够避免固定时间窗口的临界问题,但是这样显然是比固定窗口更加耗时的。

了解完了我们的限流策略和判定方法之后,我们在Sentinel中进行实际测试一下,打开管理页面的簇点链路模块:

这里演示对我们的借阅接口进行限流,点击流控,会看到让我们添加流控规则:

  • 阈值类型:QPS就是每秒钟的请求数量,并发线程数是按服务当前使用的线程数据进行统计的。
  • 流控模式:当达到阈值时,流控的对象,这里暂时只用直接。
  • 流控效果:就是我们上面所说的三种方案。

这里我们选择QPS、阈值设定为1,流控模式选择直接、流控效果选择快速失败,可以看到,当我们快速地进行请求时,会直接返回失败信息:

最后我们来看看这些流控模式有什么区别:

  • 直接:只针对于当前接口。
  • 关联:当其他接口超过阈值时,会导致当前接口被限流。
  • 链路:更细粒度的限流,能精确到具体的方法。

关联模式限流是作用于关联资源的,一旦发现关联资源超过阈值,那么就会对当前的资源进行限流。

链路模式,它能够更加精准的进行流量控制,链路流控模式指的是,当从指定接口过来的资源请求达到限流条件时,开启限流,这里得先讲解一下@SentinelResource的使用。

我们可以对某一个方法进行限流控制,无论是谁在何处调用了它,这里需要使用到@SentinelResource,一旦方法被标注,那么就会进行监控,比如我们这里创建两个请求映射,都来调用Service的被监控方法:

@RestController
public class BorrowController {

    @Resource
    BorrowService service;

    @RequestMapping("/borrow/{uid}")
    UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){
        return service.getUserBorrowDetailByUid(uid);
    }

    @RequestMapping("/borrow2/{uid}")
    UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid){
        return service.getUserBorrowDetailByUid(uid);
    }
}
@Service
public class BorrowServiceImpl implements BorrowService{

    @Resource
    BorrowMapper mapper;

    @Resource
    UserClient userClient;

    @Resource
    BookClient bookClient;

    @Override
    @SentinelResource("getBorrow")   //监控此方法,无论被谁执行都在监控范围内,这里给的value是自定义名称,这个注解可以加在任何方法上,包括Controller中的请求映射方法,跟HystrixCommand贼像
    public UserBorrowDetail getUserBorrowDetailByUid(int uid) {
        List<Borrow> borrow = mapper.getBorrowsByUid(uid);
        User user = userClient.getUserById(uid);
        List<Book> bookList = borrow
                .stream()
                .map(b -> bookClient.getBookById(b.getBid()))
                .collect(Collectors.toList());
        return new UserBorrowDetail(user, bookList);
    }
}

添加配置文件:

spring:
  application:
    name: borrowservice
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8858
      # 关闭Context收敛,这样被监控方法可以进行不同链路的单独控制
      web-context-unify: false

然后我们在Sentinel控制台中添加流控规则,注意是针对此方法,可以看到已经自动识别到borrow接口下调用了这个方法:

最后我们在浏览器中对这两个接口都进行测试,会发现,无论请求哪个接口,只要调用了Service中的getUserBorrowDetailByUid这个方法,都会被限流。注意限流的形式是后台直接抛出异常,至于怎么处理我们后面再说。

那么这个链路选项实际上就是决定只限流从哪个方向来的调用,比如我们只对borrow2这个接口对getUserBorrowDetailByUid方法的调用进行限流,那么我们就可以为其指定链路:

然后我们会发现,限流效果只对我们配置的链路接口有效,而其他链路是不会被限流的。

除了直接对接口进行限流规则控制之外,我们也可以根据当前系统的资源使用情况,决定是否进行限流:

系统规则支持以下的模式:

  • Load 自适应(仅对 Linux/Unix-like 机器生效):系统的 load1 作为启发指标,进行自适应系统保护。当系统 load1 超过设定的启发值,且系统当前的并发线程数超过估算的系统容量时才会触发系统保护(BBR 阶段)。系统容量由系统的 maxQps * minRt 估算得出。设定参考值一般是 CPU cores * 2.5
  • CPU usage(1.5.0+ 版本):当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0),比较灵敏。
  • 平均 RT:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。
  • 并发线程数:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。
  • 入口 QPS:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。

限流和异常处理

之前,我们学习了如何进行限流操作,但我们发现限流之后返回的是Sentinel默认的数据,,那么我们该如何修改默认数据呢?

首先我们需要创建一个限流状态下需要返回的内容。定义一个请求映射:

@RequestMapping("/blocked")
JSONObject blocked(){
    JSONObject object = new JSONObject();
    object.put("code", 403);
    object.put("success", false);
    object.put("massage", "您的请求频率过快,请稍后再试!");
    return object;
}

再在配置文件里将其设置为限流页面

spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8858
      # 将刚刚编写的请求映射设定为限流页面
      block-page: /blocked

这样,当被限流时,就和被重定向到指定页面:

那么,对于方法级别的限流呢?经过前面的学习我们知道,当某个方法被限流时,会直接在后台抛出异常,那么这种情况我们该怎么处理呢,比如我们之前在Hystrix中可以直接添加一个替代方案,这样当出现异常时会直接执行我们的替代方法并返回,Sentinel也可以。

比如我们还是在getUserBorrowDetailByUid方法上进行配置:

@Override
@SentinelResource(value = "getBorrow", blockHandler = "blocked")   //指定blockHandler,也就是被限流之后的替代解决方案,这样就不会使用默认的抛出异常的形式了
public UserBorrowDetail getUserBorrowDetailByUid(int uid) {
    List<Borrow> borrow = mapper.getBorrowsByUid(uid);
    User user = userClient.getUserById(uid);
    List<Book> bookList = borrow
            .stream()
            .map(b -> bookClient.getBookById(b.getBid()))
            .collect(Collectors.toList());
    return new UserBorrowDetail(user, bookList);
}

//替代方案,注意参数和返回值需要保持一致,并且参数最后还需要额外添加一个BlockException
public UserBorrowDetail blocked(int uid, BlockException e) {
    return new UserBorrowDetail(null, Collections.emptyList());
}

这样一旦方法被限流,就会执行替代方案。

注意blockHandler只能处理限流情况下抛出的异常,包括下面即将要介绍的热点参数限流也是同理,如果是方法本身抛出的其他类型异常,不在管控范围内,但是可以通过其他参数进行处理:

@RequestMapping("/test")
@SentinelResource(value = "test",
        fallback = "except",    //fallback指定出现异常时的替代方案
        exceptionsToIgnore = IOException.class)  //忽略那些异常,也就是说这些异常出现时不使用替代方案
String test(){
    throw new RuntimeException("HelloWorld!");
}

//替代方法必须和原方法返回值和参数一致,最后可以添加一个Throwable作为参数接受异常
String except(Throwable t){
    return t.getMessage();
}

特别注意这种方式会在没有配置blockHandler的情况下,将Sentinel机制内(也就是限流的异常)的异常也一并处理了,如果配置了blockHandler,那么在出现限流时,依然只会执行blockHandler指定的替代方案(因为限流是在方法执行之前进行的)

热点参数限流

我们还可以对某一热点数据进行精准限流,比如在某一时刻,不同参数被携带访问的频率是不一样的:

  • http://localhost:8301/test?a=10 访问100次
  • http://localhost:8301/test?b=10 访问0次
  • http://localhost:8301/test?c=10 访问3次

由于携带参数a的请求比较多,我们就可以只对携带参数a的请求进行限流。

这里我们创建一个新的测试请求映射:

@RequestMapping("/test")
@SentinelResource("test")   //注意这里需要添加@SentinelResource才可以,用户资源名称就使用这里定义的资源名称
String findUserBorrows2(@RequestParam(value = "a", required = false) int a,
                        @RequestParam(value = "b", required = false) int b,
                        @RequestParam(value = "c",required = false) int c) {
    return "请求成功!a = "+a+", b = "+b+", c = "+c;
}

启动之后,我们在Sentinel里面进行热点配置:

然后开始访问我们的测试接口,可以看到在携带参数a时,当访问频率超过设定值,就会直接被限流,这里是直接在后台抛出异常:

除了直接对某个参数精准限流外,我们还可以对参数携带的指定值单独设定阈值,比如我们现在不仅希望对参数a限流,而且还希望当参数a的值为10时,QPS达到5再进行限流,那么就可以设定例外:

这样,当请求携带参数a,且参数a的值为10时,阈值将按照我们指定的特例进行计算。

服务熔断和降级

如果在某一时刻,服务B出现故障(可能就卡在那里了),而这时服务A依然有大量的请求,在调用服务B,那么,由于服务A没办法再短时间内完成处理,新来的请求就会导致线程数不断地增加,这样,CPU的资源很快就会被耗尽。

那么要防止这种情况,就只能进行隔离了,这里我们提两种隔离方案:

1.线程池隔离

线程池隔离实际上就是对每个服务的远程调用单独开放线程池,比如服务A要调用服务B,那么只基于固定数量的线程池,这样即使在短时间内出现大量请求,由于没有线程可以分配,所以就不会导致资源耗尽了。

2.信号量隔离

信号量隔离是使用Semaphore类实现的(如果不了解,可以观看本系列 并发编程篇 视频教程),思想基本上与上面是相同的,也是限定指定的线程数量能够同时进行服务调用,但是它相对于线程池隔离,开销会更小一些,使用效果同样优秀,也支持超时等。

Sentinel也正是采用的这种方案实现隔离的。

当下游服务因为某种原因变得不可用或响应过慢时,上游服务为了保证自己整体服务的可用性,不再继续调用目标服务而是快速返回或是执行自己的替代方案,这便是服务降级。

整个过程分为三个状态:

  • 关闭:熔断器不工作,所有请求全部该干嘛干嘛。
  • 打开:熔断器工作,所有请求一律降级处理。
  • 半开:尝试进行一下下正常流程,要是还不行继续保持打开状态,否则关闭。

我们来看看Sentinel中如何进行熔断和降级操作,打开管理页面,我们可以自由新增熔断规则:

其中,熔断策略有三种模式:

1.慢调用比例:

如果出现那种半天都处理不完的调用,有可能就是服务出现故障,导致卡顿,这个选项是按照最大响应时间(RT)进行判定,如果一次请求的处理时间超过了指定的RT,那么就被判定为慢调用,在一个统计时长内,如果请求数目大于最小请求数目,并且被判定为慢调用的请求比例已经超过阈值,将触发熔断。经过熔断时长之后,将会进入到半开状态进行试探(这里和Hystrix一致)

我们模拟一下慢调用:

@RequestMapping("/borrow2/{uid}")
UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) throws InterruptedException {
    Thread.sleep(1000);
    return null;
}

创建熔断规则:

可以看到,超时直接触发了熔断,进入到阻止页面:

2.异常比例:

这个与慢调用比例类似,不过这里判断的是出现异常的次数,与上面一样,我们也来进行一些小测试:

@RequestMapping("/borrow2/{uid}")
UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) {
    throw new RuntimeException();
}

此时我们访问会不断报出异常,并进行熔断。

3.异常数

这个和上面的唯一区别就是,只要达到指定的异常数量,就熔断

那么,如何自定义服务降级呢?

同样的,我们只需要在@SentinelResource中配置blockHandler参数(那这里跟前面那个方法限流的配置不是一毛一样吗?没错,因为如果添加了@SentinelResource注解,那么这里会进行方法级别细粒度的限制,和之前方法级别限流一样,会在降级之后直接抛出异常,如果不添加则返回默认的限流页面,blockHandler的目的就是处理这种Sentinel机制上的异常,所以这里其实和之前的限流配置是一个道理,因此下面熔断配置也应该对value自定义名称的资源进行配置,才能作用到此方法上):

@RequestMapping("/borrow2/{uid}")
@SentinelResource(value = "findUserBorrows2", blockHandler = "test")
UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) {
    throw new RuntimeException();
}

UserBorrowDetail test(int uid, BlockException e){
    return new UserBorrowDetail(new User(), Collections.emptyList());
}

接着我们对进行熔断配置,注意是对我们添加的@SentinelResource中指定名称的findUserBorrows2进行配置:

之后我们在熔断之后,就能看到降级的效果了。

最后我们来看一下如何让Feign的也支持Sentinel,前面我们使用Hystrix的时候,就可以直接对Feign的每个接口调用单独进行服务降级,而使用Sentinel,也是可以的,首先我们需要在配置文件中开启支持:

feign:
  sentinel:
    enabled: true

之后的步骤其实和之前是一模一样的,首先创建实现类:

@Component
public class UserClientFallback implements UserClient{
    @Override
    public User getUserById(int uid) {
        User user = new User();
        user.setName("我是替代方案");
        return user;
    }
}

在原服务的@FeignClient注解下,添加fallback属性:

@FeignClient(value = "userservice",fallback = UserClientFallback.class)
public interface UserClient {
    @RequestMapping("/user/{uid}")
    User getUserById(@PathVariable("uid") int uid);
}

这样当我们将User服务关闭,就能使用降级方案了:

那么传统的RestTemplate呢?我们可以使用@SentinelRestTemplate注解实现:

@Bean
  @LoadBalanced
  @SentinelRestTemplate(blockHandler = "handleException", blockHandlerClass = ExceptionUtil.class,
      fallback = "fallback", fallbackClass = ExceptionUtil.class) //这里同样可以设定fallback等参数
  public RestTemplate restTemplate() {
    return new RestTemplate();
  }