使用 Sentinel 为你的系统保驾护航——以重要接口限流为例
前言
一个合格的网站,或者说接口服务,至少需要为一些重要的接口提供限流功能,特别是一些涉及很多 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接口,我们就可以看到该接口的限流情况了,加快访问频率,还可能遇到错误码429:

以及控制台输出:
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);
}
}
本文由「黄阿信」创作,创作不易,请多支持。
如果您觉得本文写得不错,那就点一下「赞赏」请我喝杯咖啡~
商业转载请联系作者获得授权,非商业转载请附上原文出处及本链接。
关注公众号,获取最新动态!
