要么改变世界,要么适应世界

使用 Sentinel 为你的系统保驾护航——以重要接口限流为例

2025-01-21 15:42:57
0
目录

前言

一个合格的网站,或者说接口服务,至少需要为一些重要的接口提供限流功能,特别是一些涉及很多 I/O 操作或者计算操作的功能,否则这些接口很容易被一些攻击者作为DDoS攻击的切入点,例如说一个验证码功能,在生成验证码的时候需要花费一些时间以供CPU计算,攻击者可能会反复调用这个接口,使得服务器忙于处理这一批请求,导致其他请求受到影响(当然现在的各种框架能够很好地解决并发问题,使得其他请求也能够得到响应,但是我们也不得不承认当CPU忙碌的时候,其他线程或者进程的处理速度也会变慢)。

限流框架

开源市场上面有许多可以做到限流的框架,实际上很多框架支持的不仅仅是限流,很多还做到了熔断、降级等功能,如下面的几个项目

  • Guava:Guava 是一组来自 Google 的核心 Java 库,包括新的集合类型(如 multimap multiset)、不可变集合、图形库以及用于并发、I/O、散列、缓存、原语、字符串等的实用程序;其中Ratelimiter用于限流,该类基于令牌桶算法实现流量限制功能。
  • concurrency-limits: concuurency-limits 是 Netflix 推出的自适应限流组件,借鉴了 TCP 相关拥塞控制算法,主要是根据请求延时,及其直接影响到的排队长度来进行限流窗口的动态调整。注意,该框架已经很久没有更新了。
  • Resilience4j:由 Spring 官方推出,Resilience4j 提供了一系列模块化的组件,旨在帮助开发者构建具备高弹性和容错能力的应用程序。这些核心组件各自承担不同的职责,能够单独使用或组合使用,以应对各种故障场景。
  • Sentinel:由阿里巴巴推出,面向分布式、多语言异构化服务架构的流量治理组件,社区活跃度很高,很多公司都在使用,下面就以该框架使用为例。

背景介绍

本人的博客系统中,在【登录】功能,使用的是 PoW 机制,之前有写过一篇【文章】来介绍,这个功能涉及三个接口:

  • 获取 PoW 对象接口:该接口会产生一个随机字符串,花费一定量的计算。
  • 验证 PoW 对象接口:验证用户提交的 Nonce 是否满足条件,花费比较多的计算。
  • 验证账户密码接口:在通过上个接口验证的有效性前提下,验证密码,花费比较多的计算和I/O

前两个接口比较需要限流(第三个接口中,如果发现用户没有进行有效的PoW,会提前返回,不会查询数据库),需要根据 IP 来限流。

此外,还有一个【友链】接口,它读取的是本地文件,该接口也要限流,但是该接口我不想针对同一个 IP ,而是想针对所有用户。

Sentinel 简介

Sentinel 提供了流量控制、熔断降级、热点参数限流等功能,还提供控制面板功能,在控制台编写规则,实时生效。

官方文档地址:【Sentinel】

基本概念

  • 资源:关键性概念,可以理解成一段代码,只要通过 Sentinel API 定义的代码,就是资源,能够被 Sentinel 保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。
  • 规则:围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。例如对于流量规则,可以规定某个资源被访问的QPS不超过一个阈值,对于熔断规则,可以规定在指定时间内访问资源时遇到异常次数不超过某个二阈值。

接入 Sentinel

引入依赖

我的博客网站是使用Spring Boot搭建的,可以使用下面的方式引入,在 Maven 工程的 pom.xml中加入以下依赖:

<!-- https://mvnrepository.com/artifact/com.alibaba.cloud/spring-cloud-starter-alibaba-sentinel -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    <version>2021.1</version>
</dependency>

注意,我的Spring Boot项目的版本是2.4.2,其他版本的请参照官方 wiki 的【版本说明】

然后在application.yml添加配置:

spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8085  # Sentinel 控制台地址
        port: 8719                 # Sentinel 连接端口

dashboard后面会介绍。

Sentinel 做了很多的开源框架适配,可以在【这里】找到各种说明

定义资源

官方提供了注解功能用于定义资源,下面以友情链接接口为例,我们需要使用@SentinelResource注解:

// Controller    

	@SentinelResource(
            value= SentinelConstant.LINK_RULE,
            blockHandler="getLinksBlockHandler",
            blockHandlerClass= SentinelLink.class
    )
    @GetMapping("/link")
    public ResponseEntity getLinks()  {
        HashMap<String, Object> map = new HashMap<>();
        List<Link> links = linkService.getAllLink();
        map.put("links", links);
        return new ResponseEntity(map, HttpStatus.OK);
    }

LINK_RULE是一个字符串:

public interface SentinelConstant {
    String LINK_RULE = "/link";
}

上面定义了一个名字为/link的资源,当触发限流或者熔断时,会触发SentinelLink#getLinksBlockHandler方法,注意,blockHandler 函数访问范围需要是 public,返回类型需要与原方法相匹配,参数类型需要和原方法相匹配并且最后加一个额外的参数,类型为 BlockException。blockHandler 函数默认需要和原方法在同一个类中。若希望使用其他类的函数,则可以指定 blockHandlerClass 为对应的类的 Class 对象,注意对应的函数必需为 static 函数,否则无法解析。

SentinelLink.java内容为:

import com.alibaba.csp.sentinel.slots.block.BlockException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

public class SentinelLink {

    private static final Logger logger = LoggerFactory.getLogger(SentinelLink.class);

    public static ResponseEntity getLinksBlockHandler(BlockException blex){
        logger.warn("限流了");
        return new ResponseEntity(null, HttpStatus.TOO_MANY_REQUESTS);
    }
}

定义规则

定义好资源后,我们就可以开始定义规则了,用于规定什么时候触发限流或者熔断。

规则可以在控制台定义,也可以以硬编码的形式定义,下面以硬编码的形式定义:

@Component
public class SentinelConfig {

    @PostConstruct
    public void initRules() throws Exception {
        initFlowRules();
    }

    /**
     * 初始化限流规则
     */
    public void initFlowRules() {

        // 限流规则
        FlowRule linkRule = new FlowRule(SentinelConstant.LINK_RULE)
                .setGrade(RuleConstant.FLOW_GRADE_QPS) // 设置模式为 QPS
                .setCount(2); // 每秒最多两次
        // 将规则加载到 Sentinel
        FlowRuleManager.loadRules(Arrays.asList(linkRule));
    }

}

控制面板

实际上,经过上面的步骤后,我们就算是接入 Sentinel 成功了。但是为了有一个可视化面板,我们可以引入 Sentinel 的控制面板,控制面板是一个单独的jar包,可以在【这里】下载,注意要和【版本说明】里边描述的一致,例如我的是sentinel-dashboard-1.8.0.jar,然后使用下面的命令运行:

java -Dserver.port=8085 -Dcsp.sentinel.dashboard.server=localhost:8085 -Dproject.name=blog-sentinel-dashboard -jar sentinel-dashboard-1.8.0.jar

运行成功后,访问 localhost:8085 即可访问到控制面板后台,默认用户名和密码都为sentinel

访问我们受保护的/link接口,我们就可以看到该接口的限流情况了,加快访问频率,还可能遇到错误码429image-20250121231239053

以及控制台输出:

2025-01-21 23:09:24.178  INFO 26956 --- [0.0-8080-exec-6] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:09:24.980  INFO 26956 --- [0.0-8080-exec-9] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:09:24.980  WARN 26956 --- [0.0-8080-exec-9] top.yalexin.rblog.sentinel.SentinelLink  : 限流了
2025-01-21 23:09:26.882  INFO 26956 --- [0.0-8080-exec-8] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:09:29.151  INFO 26956 --- [.0-8080-exec-10] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:11:39.698  INFO 26956 --- [0.0-8080-exec-2] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:11:40.131  INFO 26956 --- [0.0-8080-exec-4] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:11:40.131  WARN 26956 --- [0.0-8080-exec-4] top.yalexin.rblog.sentinel.SentinelLink  : 限流了
2025-01-21 23:12:01.174  INFO 26956 --- [0.0-8080-exec-7] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:12:01.610  INFO 26956 --- [0.0-8080-exec-6] top.yalexin.rblog.apsect.LogAspect       : request : {url='http://localhost:8080/api/link', ip='0:0:0:0:0:0:0:1', className='top.yalexin.rblog.controller.LinkController.getLinks', args=[]}
2025-01-21 23:12:01.610  WARN 26956 --- [0.0-8080-exec-6] top.yalexin.rblog.sentinel.SentinelLink  : 限流了

证明我们的限流规则已经生效。

值得注意的是,该方式限流是针对整个应用的,即该接口只允许每秒被访问 2 次,而不管是任何人。

因此该方法存在一些缺点,即一个攻击者,不停地访问该接口,导致该接口间断性地发生限流,其他人将几乎得不到该接口的正常服务,实际上更好的办法是每个 IP 的访问次数是隔离的,限流也是针对不同的 IP 。

如果登录接口也是使用该方法来进行隔离的话,那么被攻击时,管理员将难以进行正常的登录。

为了达到针对不同的 IP 限流,需要使用 Sentinel 的【热点参数限流】功能

热点参数限流

根据官方文档描述,如果我们继续使用@SentinelResource注解来定义资源,若注解作用的方法上有参数,Sentinel 会将它们作为参数传入 SphU.entry(res, args),我们的三个关于管理员登录接口的方法中,他们的参数都涉及对象类型HttpServletRequest,不利于区分每一次访问,因此需要我们手动先获取 IP ,然后以 IP 作为参数。

可以这么理解,如果还是基于注解形式使用,那么同一个 IP 前后两次访问该接口,如果产生了两个不同的HttpServletRequest对象,那么 Sentinel 将把这两次统计到不同的桶中。

当我们不再使用注解的方式时,需要我们手动定义资源,以登录接口为例,下面是定义规则:

@PostMapping("/login")
    public ResponseEntity login(HttpServletRequest request,
                                HttpServletResponse response,
                                @RequestBody JSONObject json) {
        // 基于 IP 限流
        String remoteAddr = IPUtils.getIRealIPAddr(request);
        Entry entry = null;
        try {
            entry = SphU.entry(SentinelConstant.ADMIN_LOGIN_RULE, EntryType.IN, 1, remoteAddr);

            // 被保护的业务逻辑
            ResponseEntity responseEntity = tryLogin(request, response, json);
            return responseEntity;
        } catch (Throwable ex) {
            // 业务异常
            if (!BlockException.isBlockException(ex)) {
                Tracer.trace(ex);
                return new ResponseEntity(null, HttpStatus.OK);
            }
            // 降级操作
            if (ex instanceof DegradeException) {
                return new ResponseEntity(null, HttpStatus.OK);
            }
            // 限流操作
            return new ResponseEntity(null, HttpStatus.TOO_MANY_REQUESTS);
        } finally {
            if (entry != null) {
                entry.exit(1, remoteAddr);
            }
        }
    }

SphU.entry的第一个参数是资源名字,第二个是类型,第三个是每次累加的大小,第四个是参数。

定义规则跟之前的类似:

public void initFlowRules() {

    // 限流规则
    ParamFlowRule adminLoginRule = new ParamFlowRule(SentinelConstant.ADMIN_LOGIN_RULE)
            .setParamIdx(0) // 对第 0 个参数限流,即 IP 地址
            .setCount(3) // 每分钟最多 3 次
            .setDurationInSec(60); // 规则的统计周期为 60 秒
     // 将规则加载到 Sentinel
    ParamFlowRuleManager.loadRules(Arrays.asList(adminLoginRule));
}

持久化规则

默认情况下,我们在控制台手动添加的规则是保存到内存中的,下次重启时将会失效,如果想要持久化,最完美情况可以借助一些配置中心框架,如果只是想简单地持久化,可以借助本地文件形式持久化,只需要修改一下我们的配置类:

public class SentinelConfig {

    @PostConstruct
    public void initRules() throws Exception {
        initLocalDataSource();
        initFlowRules();
        initDegradeRules();

    }

    /**
     * 初始化限流规则
     */
    public void initFlowRules() {

        // 限流规则
        ParamFlowRule adminLoginRule = new ParamFlowRule(SentinelConstant.ADMIN_LOGIN_RULE)
                .setParamIdx(0) // 对第 0 个参数限流,即 IP 地址
                .setCount(3) // 每分钟最多 3 次
                .setDurationInSec(60); // 规则的统计周期为 60 秒

        ParamFlowRule adminGetPoWConfig = new ParamFlowRule(SentinelConstant.ADMIN_POW_RULE)
                .setParamIdx(0) // 对第 0 个参数限流,即 IP 地址
                .setCount(10) // 每分钟最多 10 次
                .setDurationInSec(60); // 规则的统计周期为 60 秒

        ParamFlowRule adminVerifyPoW = new ParamFlowRule(SentinelConstant.ADMIN_POW_VERIFY_RULE)
                .setParamIdx(0) // 对第 0 个参数限流,即 IP 地址
                .setCount(5) // 每分钟最多 10 次
                .setDurationInSec(60); // 规则的统计周期为 60 秒
        // 将规则加载到 Sentinel
        ParamFlowRuleManager.loadRules(Arrays.asList(adminLoginRule, adminGetPoWConfig, adminVerifyPoW));

        FlowRule linkRule = new FlowRule(SentinelConstant.LINK_RULE)
                .setGrade(RuleConstant.FLOW_GRADE_QPS) // 设置模式为 QPS
                .setCount(3); // 每秒最多两次
        // 将规则加载到 Sentinel
        FlowRuleManager.loadRules(Arrays.asList(linkRule));
    }

    /**
     * 初始化降级规则
     */
    public void initDegradeRules() {
        // 熔断规则
    }

    /**
     * 持久化配置为本地文件
     */
    public void initLocalDataSource() throws Exception {
        // 获取项目根目录
        String rootPath = System.getProperty("user.dir");
        // sentinel 目录路径
        File sentinelDir = new File(rootPath, "sentinel");
        // 目录不存在则创建
        if (!sentinelDir.exists()) {
            sentinelDir.mkdirs();
        }
        // 规则文件路径
        String flowRulePath = new File(sentinelDir, "FlowRule.json").getAbsolutePath();
        String degradeRulePath = new File(sentinelDir, "DegradeRule.json").getAbsolutePath();

        // Data source for FlowRule
        ReadableDataSource<String, List<FlowRule>> flowRuleDataSource = new FileRefreshableDataSource<>(flowRulePath, flowRuleListParser);
        // Register to flow rule manager.
        FlowRuleManager.register2Property(flowRuleDataSource.getProperty());
        WritableDataSource<List<FlowRule>> flowWds = new FileWritableDataSource<>(flowRulePath, this::encodeJson);
        // Register to writable data source registry so that rules can be updated to file
        WritableDataSourceRegistry.registerFlowDataSource(flowWds);

        // Data source for DegradeRule
        FileRefreshableDataSource<List<DegradeRule>> degradeRuleDataSource
                = new FileRefreshableDataSource<>(
                degradeRulePath, degradeRuleListParser);
        DegradeRuleManager.register2Property(degradeRuleDataSource.getProperty());
        WritableDataSource<List<DegradeRule>> degradeWds = new FileWritableDataSource<>(degradeRulePath, this::encodeJson);
        // Register to writable data source registry so that rules can be updated to file
        WritableDataSourceRegistry.registerDegradeDataSource(degradeWds);
    }

    private Converter<String, List<FlowRule>> flowRuleListParser = source -> JSON.parseObject(source,
            new TypeReference<List<FlowRule>>() {
            });
    private Converter<String, List<DegradeRule>> degradeRuleListParser = source -> JSON.parseObject(source,
            new TypeReference<List<DegradeRule>>() {
            });

    private <T> String encodeJson(T t) {
        return JSON.toJSONString(t);
    }
}
历史评论
开始评论