使用Sentinel作为Spring Boot应用限流组件过年放假期间公司 Web 服务的短信接口遭遇了恶意刷量导致阿里云账户余额直接被扣至欠费。当初实现该功能时由于心存侥幸觉得如此小规模的项目不至于被黑客盯上因此仅对同一手机号的重复请求做了简单限制并未在前端接入验证码流程也未在后端实施 IP 限流。过年回来后痛定思痛赶紧把这个技术债给还上……一、 前言对于一些核心且无需鉴权的对外接口做好限流措施是不可或缺的防线。虽然限流无法 100% 杜绝恶意攻击但至少能大幅提高恶意刷接口的成本。对于 Spring Boot/Cloud 框架开发的 Web 服务实际上有很多限流组件库可供选择例如Resilience4j (Spring Cloud 官方推荐)Bucket4j (基于令牌桶算法的 Java 限流库)Guava RateLimiter (单机限流)Hystrix (经典)Sentinel......综合考虑后我选择了Sentinel。它不仅具备上述优秀组件的特点还自带一个直观的 Dashboard 且极易上手。对于日常开发来说这种配置简单、开箱即用的工具无疑是最佳选择。二、 Spring Boot应用集成Sentinel相关链接:Sentinel的Github仓库: github.com/alibaba/Sen…Sentinel官方网站: home | Sentinel引入依赖这里使用Maven作为依赖管理工具, 在pom.xml中加入以下依赖xml!-- Sentinel -- dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-sentinel/artifactId version2023.0.3.4/version /dependency注意 如果按照官方网站的指导来做可能并非直接引入该依赖这对于刚接触的人来说比较困惑。这并不是官方文档描述有误或者过时了而是因为我们使用了 Spring Boot 作为框架Spring Cloud 开发组对其做了深度适配引入上述依赖实际上就会自动引入 Sentinel 的核心与常用依赖。配置Sentinel通过在Spring Boot的应用配置文件中进行最终配置yamlspring: cloud: # Sentinel配置 sentinel: transport: port: 8719 # 会在本地开启Http服务用于控制Sentinel dashboard: localhost:8080 # 如果不需要看板可以注释掉 eager: true # 是否提前触发 Sentinel 初始化, 建议开启, 随应用启动而初始化启动应用只需完成以上两步即可成功集成 Sentinel。它会自动将所有的 HTTP 接口注册为 Sentinel 的资源 (Resource)。启动应用以验证结果若控制台打印如下日志且无任何报错即说明集成成功vbnetINFO: Sentinel log output type is: file INFO: Sentinel log charset is: utf-8 INFO: Sentinel log base directory is: C:\Users\23111\logs\csp\ INFO: Sentinel log name use pid is: false INFO: Sentinel log level is: INFO使用Sentinel Dashboard (可选)下载Sentinel Dashboard的jar包, 从官方Github的Release中下载. 运行以下命令即可实现Dashboard的启动:ini# 如果8080端口被占用可以换成别端口, 相应地, 需要在Spring Boot应用的配置中更改过来 java -Dserver.port8080 -Dcsp.sentinel.dashboard.serverlocalhost:8080 -Dproject.namesentinel-dashboard -jar sentinel-dashboard.jar访问 http://localhost:设置的端口号 后即可进入 Sentinel 的 Web 界面默认账号密码是admin/admin。登录成功后进入首页如果左侧的菜单栏中除了当前 Dashboard 节点菜单外还有 Spring Boot 应用的名称那就说明对接成功。至于如何操作 Dashboard 此文章不做过多解释。三、使用Sentinel基于请求源IP对接口进行限流1. 选择限流的实现方案Sentinel 提供了多种限流规则。官方文档中提到若需基于 IP 限流可采用基于调用关系的流量控制。然而该方案主要适用于微服务之间已知且有限的 IP 限制。在面对公网环境下海量不可控的源 IP 时这种方式往往无法满足需求参考官方 FAQ 说明Q: 怎么针对特定调用端限流比如我想针对某个 IP 或者来源应用进行限流规则里面 limitApp流控应用的作用A: Sentinel 支持按来源限流可以参考 基于调用关系的限流。注意 origin 数量不能太多否则会导致内存暴涨并且目前不支持模式匹配。因此我们需要另辟蹊径。最终我决定采用热点参数限流。Sentinel 的热点参数限流底层基于 LRU 机制实现对于拦截公网高频恶意 IP 访问的场景非常契合且不会误伤正常用户的访问。2. 实现思路Sentinel 原生的热点参数限流要求开发者手动传入参数值作为计数 Key框架本身并不会自动提取 HTTP 请求的源 IP 并注入规则中。因此我们需要自行串联起“从请求中获取源 IP”、“将 IP 设为限流参数”、“设定限流规则”以及“触发限流逻辑”的完整流程。如果在每个需要限流的业务接口中硬编码提取 IP 和限流逻辑会对业务代码造成严重的侵入。为了保持代码的整洁与高内聚我采用了自定义注解 AOP (面向切面编程)的方式来实现既保证了高度的灵活性又实现了与业务逻辑的解耦。3. 代码参考注解定义javaimport java.lang.annotation.*; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface RateLimitByIp { /** * 资源名称如果不填默认使用 类名.方法名 */ String value() default ; /** * 【新增】单机 QPS 阈值默认 10 */ int count() default 10; /** * 【新增】统计窗口时长秒默认 1 秒 */ int duration() default 1; /** * 限流后的提示信息 */ String message() default Too busy; }限流逻辑的AOP实现java import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRule; import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRuleManager; import com.xxx.xxx.annotation.RateLimitByIp; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; import com.alibaba.csp.sentinel.Entry; import com.alibaba.csp.sentinel.EntryType; import com.alibaba.csp.sentinel.SphU; import com.alibaba.csp.sentinel.slots.block.BlockException; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import jakarta.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Slf4j Aspect Component public class IpRateLimitAspect { // 用来记录已经初始化过规则的资源避免重复加载 private static final MapString, Boolean ruleInitMap new ConcurrentHashMap(); Around(annotation(rateLimitByIp)) public Object handleRateLimit(ProceedingJoinPoint point, RateLimitByIp rateLimitByIp) throws Throwable { // 1. 获取资源名称 (如果注解没写就用 类名.方法名) String resourceName rateLimitByIp.value(); if (resourceName null || resourceName.isEmpty()) { MethodSignature signature (MethodSignature) point.getSignature(); Method method signature.getMethod(); resourceName method.getDeclaringClass().getName() . method.getName(); } // 2. 【关键优化】自动初始化规则 // 只有第一次访问该接口时才会执行规则加载逻辑 if (!ruleInitMap.containsKey(resourceName)) { initHotParamFlowRule(resourceName, rateLimitByIp.count(), rateLimitByIp.duration()); } // 3. 获取 IP ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes null) { return point.proceed(); } HttpServletRequest request attributes.getRequest(); String ip getIpAddress(request); // 4. Sentinel 埋点 Entry entry null; try { // 参数索引 0 是 IP entry SphU.entry(resourceName, EntryType.IN, 1, ip); return point.proceed(); } catch (BlockException ex) { log.error(IP访问限流: ip{}, 访问次数上限{}, 资源{}, ip, rateLimitByIp.count(), resourceName); throw new RuntimeException(rateLimitByIp.message()); } finally { if (entry ! null) { entry.exit(1, ip); } } } /** * 动态加载热点参数规则 */ private synchronized void initHotParamFlowRule(String resourceName, int count, int duration) { // 防止并发重复初始化 if (ruleInitMap.containsKey(resourceName)) return; // 1. 创建新规则 ParamFlowRule rule new ParamFlowRule(resourceName) .setParamIdx(0) // 我们的 Aspect 总是把 IP 放在第一个参数 .setCount(count) .setDurationInSec(duration); // 2. 获取当前已有的所有规则 ListParamFlowRule rules new ArrayList(ParamFlowRuleManager.getRules()); // 3. 移除旧规则 (如果存在同名的)避免重复添加 rules.removeIf(r - r.getResource().equals(resourceName)); // 4. 添加新规则 rules.add(rule); // 5. 重新加载 ParamFlowRuleManager.loadRules(rules); ruleInitMap.put(resourceName, true); log.info( [Sentinel] 自动加载 IP 限流规则: 资源{}, QPS{}, resourceName, count); } private String getIpAddress(HttpServletRequest request) { String ip request.getHeader(x-forwarded-for); if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getHeader(Proxy-Client-IP); } if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getHeader(WL-Proxy-Client-IP); } if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getRemoteAddr(); } // 多个代理的情况第一个IP为客户端真实IP if (ip ! null ip.contains(,)) { ip ip.split(,)[0].trim(); } return ip; } } 使用注解lessGetMapping(/sms/code) ResponseBody RateLimitByIp(count 3, duration 60) // 60s内最多允许3次请求 public AjaxResult sendPhoneSms(String phone) { ...... }四、结尾寄语本文分享的方案在实际应用中仍有优化空间例如通过 HTTP Header 获取源 IP 的算法在经过多层复杂反向代理时可能不够准确缺少全局的流量分析面板基于 AOP 的限流由于切面执行时机无法完全复用 Spring MVC 适配的默认接口资源名等。这也是为了快速修补安全漏洞而采取的应急方案不足之处还望海涵。另外发几句牢骚对于起步阶段的小型项目过度设计限流机制有时显得性价比不高。在项目生死未卜的阶段将大量精力投入到“防御性编程”中倒不如早点下班享受生活……然而总有一些“神人”闲来无事专挑小公司的小项目进行所谓的“技术演练”。只能说有这身手何不去打击那些真正的灰黑产诈骗网站呢。后续计划 Sentinel 默认的 Dashboard 指标数据是保存在内存中的重启就会丢失且不适合用于长期的流量分析。后续我计划直接修改 Sentinel Dashboard 的源码将这些监控指标数据持久化到时序数据库如 InfluxDB 或 TDengine中以此来搭建一个完善的流量监控面板届时再整理成文章分享出来。