Loading

Sentinel服务保护

雪崩问题

由于微服务中的某个服务出现故障无法完成任务,导致依赖于它的服务阻塞在对它的请求上,不释放连接资源,最终连接堆积,它也无法处理新的任务,这样的情况按照层级不断传递,最终使得微服务集群中的很多节点都出现相同的故障,这就是雪崩

img

雪崩问题的原因:

  1. 瞬时高并发使得服务处理的速率跟不上请求速率
  2. 服务或网络故障

雪崩问题的解决办法:

  1. 超时处理:它能一定程度上缓解雪崩问题,但如果超时时间没有连接进入的时间快,还是会出现雪崩问题
  2. 舱壁模式(线程隔离):限定每个微服务中的每个业务能使用的最大线程数,避免耗尽连接资源
    img
    该模式还是没有完全解决问题,假设服务C已经宕机,该模式的业务2还是会不断的去访问服务C,实际上这些访问只是白白浪费资源。而且,依赖于服务A的业务2的微服务不还是会阻塞嘛,虽然整体没事,但对于这条服务调用路径来说是不是可以认为依然发生了雪崩呢?
  3. 熔断降级断路器会统计对于某个服务调用的失败比例,若比例达到一定阈值,拦截访问该业务的一切请求
  4. 流量控制(预防):限制业务访问的QPS,将大量请求按照服务所能承受的QPS传递到服务中(有点类似MQ所带来的流量削峰)

Sentinel Dashboard安装的一个坑

我想替换Sentinel默认的用户名和密码,由于Sentinel控制台是一个SpringBoot项目,所以我打算在jar包目录放置一个application.yml文件,在里面按照官方文档给的参数来配置用户名和密码,官方给的配置如下:

sentinel:
    dashboard:
        auth:
            username: xxx
            password: xxx

无论我是用这还是用命令行参数都没用,后来我把这个jar包解开了,看了下官方的配置文件里是咋写的,结果哔了个狗:

img

所以,我把我的配置文件改成:

auth:
    username: xxx
    password: xxx

现在好了...

QuicStart

在需要被监控的微服务中引入Sentinel依赖

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

在配置文件中配置:

spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:10101

访问该服务的任意端点,然后去Sentinel控制台中查看被监控下来的数据:

img

同时我们可以通过簇点链路中的每个资源后面的流控按钮来控制该端口允许的最大流量

img

比如,这里我们把QPS设置为1,那么一秒钟内该资源只能被访问1次

img

一秒钟内尝试访问多次被Sentinel拦截

img

簇点链路

簇点链路是项目中的调用链路,链路中的每一个被监控的接口就是一个资源,默认情况下,Sentinel会监控SpringMVC中的每个Controller方法。

流控模式

在Sentinel流控的高级选项中,有一个流控模式选项

img

  1. 直接:当当前资源的请求到达阈值时,对当前资源进行限流
  2. 关联:当与当前相关的另一个资源的请求到达阈值时,对当前资源进行限流
  3. 链路:统计从指定链路到当前资源的请求,当触发阈值时,对指定链路限流

关联模式的示例

假设/query资源是对商品进行查询的资源,/update是支付后对商品库存进行修改的资源,我们期望/update资源的优先级更高,因为这里的用户在花钱。

所以我们可以在/update的QPS到达阈值时对/query的部分访问进行拒绝,这样也就让出了部分处理资源给/update

img

关联模式用在两个具有关联的,但一个比另一个优先级要更高的情况下

链路模式的示例

系统中有三个资源:

  1. 假设/order/query是查询订单的接口
  2. 假设/order/update是插入订单的接口
  3. 假设查询和插入订单都需要访问OrderService.queryGoods方法

还是一样的思路,我们想尽可能保证插入订单的请求被服务,因为人家花钱了。所以,我们可以针对queryGoods这个资源进行链路限流,当它是从/order/query来的,就给它一个较小的QPS,当它是从/order/update来的,就给它一个较大的QPS。

开始前的设置

Sentinel默认只会监控Controller方法作为资源,你还要将对应的Service方法声明为Sentinel资源

@SentinelResource(value = "queryGoods")
public void queryGoods() {
    System.out.println("queryGoods");
}

Sentinel默认还会开启Controller方法的context整合,虽然不知道是啥,但它会导致链路模式流控失败,可以使用以下方式关闭

spring:
  cloud:
    sentinel:
      web-context-unify: false

开启链路模式限流并测试

img

img

img

img

流控效果

高级设置中还有一个流控效果,有三种选项

  1. 快速失败:到达阈值,直接抛出FlowException,请求宣告失败
  2. warm up:效果同上,但阈值是从一个较小的值慢慢增长到最大阈值
  3. 排队等待:所有请求按照到达先后到队列中排队,新来的请求当预期等待时间超出最大时长时就会被拒绝

warm up的示例

程序刚刚启动时肯定达不到最大的QPS,随着散落在程序中、框架中、系统中的各种缓存技术、JIT优化技术的一点一点的应用,程序才能慢慢的到达最大QPS。

warm up即设定一个时间,请求阈值从threshold / coldFactor(默认为3)开始慢慢提高,等到时间到达,实际阈值才变成设定好的最大threshold

img

排队等待的示例

所有到达的请求到队列中排队,后面的请求必须等待前面的请求完成后才能执行。

img

排队等待中有一个预期等待时间的概念,当QPS=5时,意味着预期每个任务的执行时间是200ms,这时,新来的任务的预期等待时间就是队列大小 * 1000 / QPSms,而排队等待可以设置这个预期等待时间的最大值,如果新任务的预期等待时间超出这个最大值,它就会被拒绝。

img

实际上,排队等待会起到一个流量整形的作用,无论瞬时的并发量有多大,都会排到队列中慢慢处理(当超时时间够大时)。

下面是我们使用排队等待(QPS=10, timeout=10000)流控效果时,以15的QPS来发送20秒请求,但由于有队列的存在,QPS被限制在10,而且没有请求被拒绝,这都是队列的功劳。

img

当然,如果你将这个测试过程持续长些,始终以15的QPS进行发起请求,以10QPS的速度进行处理,那么队列始终会满的,然后就会出现请求被拒绝的情况,比如在当前设置下,队列最多能容纳100个请求,因为\(最大请求数 = timeout/(1000/qps)=10000/(1000/10)=100\),如果我们每秒发送15个请求,那么每秒会多出5个请求没被处理,积攒在队列中,那么,第21秒也许就有请求失败了。

所以,排队等待只是在瞬时高并发涌入时进行流量削峰填谷,如果长时间的请求QPS大于你能处理的QPS,还是多增加一些服务吧

热点参数限流

比如在微博突然发生热点事件的情况,某个博主或者某个帖子的请求访问量肯定比其它人的高很多,所以这种情况,这个博主或帖子的QPS可能需要加大,此时根据这个博主或帖子的id提供例外的限流规则比直接将博主查询和帖子查询的整个资源都应用特定的限流更加人性化。

img

下面是针对热点订单(看起来有些愚蠢就这样吧)的限流设置,默认情况下所有订单的QPS都是2,而2和3是比较热点的订单,它们的QPS是例外项,被设置成了4和10。

img

这时,需要去代码中给指定的Controller方法添加resource,这是必须的,因为Sentinel的热点参数限流对Controller那个默认被识别的资源不生效,你要想用热点参数限流就必须使用@SentinelResource重新声明它。

@GetMapping("/{id}")
@SentinelResource("hotorders")
public Order getOrderById(@PathVariable("id") Integer id) {
    return orderService.getOrderById(id);
}

上面所有的办法都是基于流量控制来对雪崩等问题做的一个预防,在服务都正常工作的情况下,它可以避免由于并发访问量过大带来的雪崩问题,而如果服务真的挂掉了,还是需要有其它的机制来避免雪崩问题的发生的

服务熔断和降级——Feign整合Sentinel

无论是线程隔离还是熔断降级,都需要在远程调用的调用方这方面做点手脚,所以这个手脚肯定要做在Feign上。

使用Feign整合Sentinel非常简单,只需要如下配置即可:

feign:
  sentinel:
    enabled: true

服务降级

服务降级是指当服务A调用服务B时,服务B无法返回完成处理,此时服务A执行一个默认的处理方式,比如返回默认数据、返回友好的错误提示......

在Feign中,所有调用都是通过@FeignClient类来完成的,我们要在这里做手脚。下面是一个简单的@FeignClient类——UserClient

@FeignClient(name = "user-service", configuration = FeignClientConfiguration.class)
public interface UserClient {
    @GetMapping("/user/{id}")
    User findById(@PathVariable("id") Integer id);
}

UserClient是一个接口,每一个方法对应一个Feign远程调用,这个接口的实现类由Feign来生成。

@FeignClient注解有个fallbackFactory参数,它接收一个FallbackFactory,这个东西用于创建一个FeignClient的实现类,该实现类中的方法就是原始的实现类的远程调用失败时走的默认方法,也可以说是降级方法。

编写FallbackFactory

在这个UserClientFallbackFactory中,我们创建了一个UserClient的Fallback实现类,它在findById调用失败时返回一个默认的User,虽然实际业务中我们不会这么做,但这里只是个演示。

@Slf4j
@Component
public class UserClientFallbackFactory implements FallbackFactory<UserClient> {
    @Override
    public UserClient create(Throwable throwable) {
        return new UserClient() {
            @Override
            public User findById(Integer id) {
                User user = new User();
                user.setId(-1);
                user.setNickname("UNKNOWN ERROR");
                log.warn(throwable.getMessage(), throwable);
                return user;
            }
        };
    }
}

可能出现一个循环引用的问题,这是因为SpringCloudAlibaba和SpringCloud版本不一致所致,后来我使用了Hoxton.SR9SpringAlibaba 2.2.6.RELEASE的组合,官方的版本关系图在这

然后还有个问题可能就是UserClientFallbackFactory所在的包没有被Spring扫描到,可以在主程序中将该类所在的包设为基包,或者直接在主程序中声明这个Bean,反正你就让它能被Spring扫描到就行:

@SpringBootApplication(scanBasePackageClasses = {OrderServiceApplication.class, UserClientFallbackFactory.class})

从上面的代码中来看,create中携带远程调用发生的实际异常,而我们可以在Fallback客户端实现类中通过闭包来使用这些异常,但是这样的话就是每次异常发生时都通过create创建一个Fallback客户端实现类了。这不是会产生大量用完即扔的小对象嘛??(这是我提出的疑问)

然后,将它设置到@FeignClientfallbackFactory属性中

@FeignClient(name = "user-service",
        configuration = FeignClientConfiguration.class,
        fallbackFactory = UserClientFallbackFactory.class)

再次访问order-service,Feign的资源已经被添加到簇点链路上

img

现在给user-service停了,再去访问order-service,可以看到Fallback客户端的结果已经被返回回来了

img

线程隔离

线程隔离是解决雪崩的一个手段,在开头就说过,它的基本思路就是为服务中的每个业务提供指定数量的线程,如果该业务的访问量超过它的线程数,那么新到的访问就会被拒绝。

线程隔离的两种方式

  1. 线程池:通过为每个业务维护一个固定大小的线程池,将业务提交到线程池中。这种方式的缺点是业务的实际处理并不由Web服务器的线程来处理,而是需要额外的线程,耗费资源
  2. 信号量:通过为每个业务维护一个信号量,当信号量为0时,新到请求被拒绝

img

这两种方式各有优缺点,Sentinel中提供了两种方式,并且默认是信号量方式。

  1. 线程池方式
    • 优点
      1. 可以对执行业务的线程进行控制,比如检测它执行时间过长,终止执行
      2. 不使用Web服务器的原始线程,所以支持服务的异步调用
    • 缺点
      1. 额外开销较大
        适合低扇出场景,即依赖的其它服务比较少的情况,这样额外线程就较少
  2. 信号量方式
    • 优点
      1. 轻量级,无额外开销
    • 缺点
      1. 不支持主动超时
      2. 不支持异步调用
        适合高扇出场景,即依赖其它服务比较多的情况,比如网关

这两种方式几乎是互补的,适用于不同的场景

开启线程隔离

img

和使用QPS进行限流的方式一样,只需要选中并发线程数即可使用线程隔离。

下图为使用Jmeter同时开启10个并发线程并使用JSON Assertion进行测试得到的结果(因为我们开启了Fallback客户端,所以在默认情况下的只验证状态码已经没用了)

img

熔断降级

熔断降级是通过断路器来根据某服务调用的统计信息,实现在该服务异常比例过高时对服务进行熔断的处理雪崩的方式

下面是断路器的有限状态机:

img

  1. 最初断路器处于Closed状态,此时对服务的所有访问都放行
  2. 当熔断策略发现应该熔断了(比如失败比例达到一定阈值),断路器进入Open状态,对于该服务所有的访问都快速失败
  3. 由于挂掉的服务可能被修复,所以当熔断时间结束,断路器进入Half Open状态,此时的断路器尝试放行一次请求,如果该请求成功,进入Closed状态,否则重新进入Open状态

熔断策略

上面提到了熔断策略,这说明Sentinel提供了多种判断是否应该熔断的方式

  1. 慢调用:当业务响应时间大于指定阈值,这次调用就是一次慢调用,当慢调用的比例在所有调用中达到指定阈值,触发熔断
  2. 异常比例
  3. 异常数

慢调用

下面是一个对用户服务设置的一个慢调用熔断策略。

img

  1. 最大RT:代表该服务的最大响应时间,如果超出,被认为是慢调用,单位ms
  2. 比例阈值:代表一段时间内慢调用的比例超过多少后熔断
  3. 统计时长/最小请求数:统计最近x毫秒内的x次请求
  4. 熔断时长:熔断后多长时间进入Half-Open状态

所以,上面的设置就是,在1秒内统计至少5个请求数,并且如果其中响应时间超过50ms的请求数达到40%就触发熔断,并且熔断时间为5秒

修改user-service的controller代码,模拟慢调用:

@GetMapping("/{id}")
public User getUserById(@PathVariable("id") Integer id) throws InterruptedException {
    if (id == 1) {
        Thread.sleep(60);
    }
    return userRepository.selectById(id);
}

测试,对id为2的查询都通过了,对id为1的只有部分通过了

img

2/5刚好是0.4,所以只有一个请求通过,第二个就被断路器给拦了。

异常数/异常比例

和慢调用相比比较简单。

这就是1秒内的5个请求,发生异常的达到一半,就熔断

img

这就是1秒内5个请求,2个发生异常,就熔断

img

授权管理

对于一个业务,我们有时希望只有特定的应用能访问它,其它的应用不能,Sentinel的授权管理可以实现这个功能。

这看似和API网关的功能重复了,但其实并不是,API网关主要的职责还是给系统提供一个统一的门面并且隐藏系统内部结构,但一旦内部结构遭到泄露,恶意用户可能绕过API网关直接对系统内部进行访问

一个常见的需求就是对于一个业务,我们只允许来自API网关(可能还有其它依赖于它的业务)的请求通过,其它的请求直接阻塞。此时我们可以在Sentinel控制台中添加授权规则:

img

上面的图片显示,对于/order/{id}业务的调用,只有gateway这个应用能通过。

RequestOriginParser

但是,目前还没有任何信息能让Sentinel区分用户的直接请求和来自API网关的请求。RequestOriginParser可以做到这点,它接受一个HttpServletRequest,通过这个对象对来自不同应用的请求进行区分。

public interface RequestOriginParser {
    // 通过req解析应用名
    String parseOrigin(HttpServletRequest req);
}

这需要你的应用发起能够被Sentinel识别的包含自己特殊标识的Http请求,比如可以在Http头中加入特殊字段,下面是在网关的请求头中添加了origin: gateway

spring:
  gateway:
    default-filters:
      - AddRequestHeader=origin,gateway

然后,我们可以实现一个RequestOriginParser来通过请求头解析应用:

@Component
public class OriginParser implements RequestOriginParser {
    @Override
    public String parseOrigin(HttpServletRequest httpServletRequest) {
        String origin = httpServletRequest.getHeader("origin");
        return StringUtil.isNullOrEmpty(origin) ? "blank" : origin;
    }
}

上面的代码就是使用http头中的origin字段作为应用名,如果该字段为空,则应用名为blank,现在,我们的授权规则已经可以使用了,只有来自网关的请求才能通过。

阻塞异常处理器

Sentinel会在一些时候通过抛出异常阻请求的访问,比如流控、热点流控、权限、服务降级等,默认的异常页面对用户很不友好,可以通过实现BlockExceptionHandler来实现自己的异常处理器

@Component
public class DefaultBlockExceptionHandler implements BlockExceptionHandler {

    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, BlockException e) throws Exception {
        if (e instanceof FlowException) {
            write(httpServletResponse, 429, "目前访问量过高,请稍后再试...");
        } else if (e instanceof DegradeException) {
            write(httpServletResponse, 429, "请求降级");
        } else if (e instanceof ParamFlowException) {
            write(httpServletResponse, 429, "目前访问量过高2,请稍后再试...");
        } else if (e instanceof AuthorityException) {
            write(httpServletResponse, 401, "权限不足");
        } else {
            write(httpServletResponse, 429, "未知错误");
        }
    }

    private void write(HttpServletResponse resp, Integer status, String message) throws IOException {
        resp.setContentType("application/json;charset=utf-8");
        resp.setStatus(status);
        resp.getWriter().println("{\"status\": " + status + ", \"message\": \"" + message + "\" }");
    }

}
posted @ 2022-08-21 11:27  yudoge  阅读(109)  评论(0编辑  收藏  举报