某产业园API网关防刷实战:Spring Boot + Redis滑动窗口 + Sentinel热点参数限流,拦截99%恶意请求
摘要:API网关防刷方案实战 针对API网关遭遇恶意刷接口问题,本文提出了一套多层防护方案。原固定窗口限流存在临界点突刺和全局限流缺陷,导致系统崩溃。改进方案结合: Redis滑动窗口解决全局限流突刺问题 Sentinel热点参数限流实现特定参数(如员工ID)精准防护 附加IP黑名单和User-Agent校验多层拦截 方案采用Spring Cloud Gateway+Redis+Sentinel技
上个月我们的智能监控系统API网关差点被搞崩——不知道是谁搞了个脚本,恶意刷园区员工的考勤查询接口和设备数据上报接口,一分钟内来了5万多个请求,正常员工的打卡请求直接超时,设备数据也上报不上去,客户的IT部门电话被打爆了。
紧急排查后发现,我们之前只加了个简单的固定窗口限流,根本防不住:固定窗口在临界点(比如每分钟的第0秒)会有“突刺”问题,恶意脚本刚好卡在第0秒疯狂请求,直接把限流阈值冲爆了;而且固定窗口只能全局限流,不能针对特定员工ID、设备ID限流,人家换个IP、换个参数就能接着刷。
没办法,只能连夜重构防刷方案:用Redis滑动窗口做全局限流,解决固定窗口的突刺问题;用Sentinel热点参数限流做针对特定参数(比如员工ID、设备ID)的限流,防止单个参数被疯狂刷;再加个IP黑名单、User-Agent校验,多层拦截。折腾了整整3天,终于上线了:现在99%的恶意请求都被拦截在网关层,正常请求的响应时间稳定在100ms以内,客户终于满意了。
今天就把这套方案的全流程实现、踩过的坑、性能优化全部分享给你们,帮你们的API网关也穿上“防弹衣”。
一、为什么选这两个方案?不是单一方案?
先说说为什么要结合Redis滑动窗口和Sentinel热点参数限流,单一方案为什么不行:
1. 固定窗口限流的死穴(我们之前踩的坑)
固定窗口限流的原理很简单:比如每分钟允许100个请求,到了第60秒就把计数器清零。但它有个致命的“突刺”问题:比如第59秒来了100个请求,第60秒计数器清零,第61秒又来100个请求,这两秒内就有200个请求,直接把服务器冲崩。我们之前就是被这个问题搞死的。
2. Redis滑动窗口的优势
滑动窗口限流把时间窗口分成更小的“格子”,比如把1分钟分成60个1秒的格子,每个格子单独计数,然后滑动计算最近60个格子的总请求数。这样就解决了固定窗口的突刺问题,限流更平滑,更精准。而且用Redis实现,天然支持分布式环境,多个网关实例共享同一个限流计数器。
3. Sentinel热点参数限流的优势
Redis滑动窗口是全局限流,比如整个考勤查询接口每分钟允许10000个请求,但如果有人专门刷某个员工的考勤记录(比如一分钟刷1000次),全局限流根本防不住——因为1000次远低于10000次的全局阈值,但对单个员工来说,这就是恶意请求。
Sentinel的热点参数限流刚好解决这个问题:可以针对接口的特定参数(比如第0个参数是员工ID)限流,比如单个员工ID每分钟只允许查询10次,超过就拦截。而且Sentinel是阿里开源的,和Spring Boot集成非常方便,还支持控制台动态调整规则,不用改代码重启服务。
4. 两者结合:多层防护,万无一失
- 第一层:IP黑名单、User-Agent校验,拦截明显的恶意请求(比如IP在黑名单里,User-Agent是“python-requests”);
- 第二层:Redis滑动窗口全局限流,拦截整体的恶意流量;
- 第三层:Sentinel热点参数限流,拦截针对特定参数的恶意流量。
二、环境准备:版本选对,少踩一半坑
我用的版本都是经过验证的,兼容性和稳定性最好:
- JDK:17
- Spring Boot:3.2.5
- Spring Cloud Gateway:4.1.3(我们用的是Spring Cloud Gateway做API网关,你们也可以用Spring MVC的拦截器)
- Spring Data Redis:3.2.5
- Sentinel:1.8.7
- Redisson:3.25.0(可选,用来做分布式锁,不过滑动窗口用Lua脚本保证原子性,不用Redisson也行)
2.1 Maven依赖配置
先在pom.xml里加必要的依赖:
<properties>
<java.version>17</java.version>
<spring-boot.version>3.2.5</spring-boot.version>
<spring-cloud.version>2023.0.1</spring-cloud.version>
<sentinel.version>1.8.7</sentinel.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
<version>${sentinel.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-parameter-flow-control</artifactId>
<version>${sentinel.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-spring-webflux-adapter</artifactId>
<version>${sentinel.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Cloud Gateway -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Sentinel -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-parameter-flow-control</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-spring-webflux-adapter</artifactId>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
2.2 application.yml配置
spring:
application:
name: api-gateway
data:
redis:
host: localhost
port: 6379
password: 123456
database: 0
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 3000ms
cloud:
gateway:
routes:
- id: attendance-service
uri: lb://attendance-service
predicates:
- Path=/api/attendance/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
- id: device-service
uri: lb://device-service
predicates:
- Path=/api/device/**
# Sentinel配置
sentinel:
transport:
dashboard: localhost:8080
eager: true
web-context-unify: false
三、核心模块实现:从滑动窗口到热点限流,一层一层防
3.1 模块一:Redis滑动窗口全局限流(Lua脚本保证原子性)
滑动窗口的核心是原子性——因为要同时操作多个Redis键(比如每个小格子的计数器),如果不用Lua脚本,在分布式环境下会出现并发问题,导致限流不准。
第一步:写Lua脚本(保证原子性的关键)
我把Lua脚本放在resources目录下,命名为sliding_window.lua:
-- 滑动窗口限流Lua脚本
-- KEYS[1]: 限流key的前缀,比如 "rate_limit:attendance:query"
-- ARGV[1]: 窗口大小,单位毫秒,比如 60000(1分钟)
-- ARGV[2]: 每个小格子的大小,单位毫秒,比如 1000(1秒)
-- ARGV[3]: 限流阈值,比如 10000(1分钟10000次)
-- ARGV[4]: 当前时间戳,单位毫秒
local keyPrefix = KEYS[1]
local windowSize = tonumber(ARGV[1])
local bucketSize = tonumber(ARGV[2])
local threshold = tonumber(ARGV[3])
local currentTime = tonumber(ARGV[4])
-- 计算当前时间所在的格子
local currentBucket = math.floor(currentTime / bucketSize)
-- 计算窗口的起始格子
local startBucket = currentBucket - math.floor(windowSize / bucketSize) + 1
-- 清理过期的格子(窗口外的)
local keysToDelete = redis.call('KEYS', keyPrefix .. ':*')
for _, key in ipairs(keysToDelete) do
local bucket = tonumber(string.match(key, ':(%d+)$'))
if bucket < startBucket then
redis.call('DEL', key)
end
end
-- 计算当前窗口内的总请求数
local total = 0
local bucket = startBucket
while bucket <= currentBucket do
local key = keyPrefix .. ':' .. bucket
local count = redis.call('GET', key)
if count then
total = total + tonumber(count)
end
bucket = bucket + 1
end
-- 判断是否超过阈值
if total >= threshold then
return 0 -- 超过阈值,拒绝请求
else
-- 没超过阈值,当前格子的计数器+1
local currentKey = keyPrefix .. ':' .. currentBucket
redis.call('INCR', currentKey)
-- 设置过期时间,比窗口大小多一点,防止内存泄漏
redis.call('PEXPIRE', currentKey, windowSize + bucketSize)
return 1 -- 允许请求
end
这个Lua脚本的逻辑很清晰:
- 计算当前时间所在的格子和窗口的起始格子;
- 清理窗口外的过期格子,防止Redis内存泄漏;
- 统计当前窗口内的总请求数;
- 如果超过阈值,返回0(拒绝);否则当前格子计数器+1,返回1(允许)。
第二步:Spring Boot Redis配置,加载Lua脚本
@Configuration
public class RedisConfig {
@Bean
public DefaultRedisScript<Long> slidingWindowScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("sliding_window.lua"));
script.setResultType(Long.class);
return script;
}
}
第三步:写滑动窗口限流服务
@Service
public class SlidingWindowRateLimiter {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private DefaultRedisScript<Long> slidingWindowScript;
/**
* 滑动窗口限流
* @param keyPrefix 限流key前缀,比如 "rate_limit:attendance:query"
* @param windowSizeMs 窗口大小,单位毫秒
* @param bucketSizeMs 每个小格子的大小,单位毫秒
* @param threshold 限流阈值
* @return true-允许请求,false-拒绝请求
*/
public boolean tryAcquire(String keyPrefix, long windowSizeMs, long bucketSizeMs, long threshold) {
long currentTime = System.currentTimeMillis();
List<String> keys = Collections.singletonList(keyPrefix);
Long result = redisTemplate.execute(
slidingWindowScript,
keys,
windowSizeMs,
bucketSizeMs,
threshold,
currentTime
);
return result != null && result == 1;
}
}
第四步:写Spring Cloud Gateway全局过滤器,应用滑动窗口限流
@Component
@Order(-1) // 优先级最高,先执行限流
public class SlidingWindowRateLimiterFilter implements GlobalFilter {
private static final Logger log = LoggerFactory.getLogger(SlidingWindowRateLimiterFilter.class);
@Autowired
private SlidingWindowRateLimiter rateLimiter;
// 配置不同接口的限流规则
private static final Map<String, RateLimitRule> RULES = new HashMap<>();
static {
// 考勤查询接口:1分钟(60000ms)窗口,1秒(1000ms)一个格子,阈值10000
RULES.put("/api/attendance/query", new RateLimitRule(60000, 1000, 10000));
// 设备数据上报接口:1分钟窗口,1秒一个格子,阈值50000
RULES.put("/api/device/data", new RateLimitRule(60000, 1000, 50000));
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 检查是否有限流规则
RateLimitRule rule = null;
for (Map.Entry<String, RateLimitRule> entry : RULES.entrySet()) {
if (path.startsWith(entry.getKey())) {
rule = entry.getValue();
break;
}
}
if (rule == null) {
// 没有限流规则,直接放行
return chain.filter(exchange);
}
// 构造限流key前缀:rate_limit:接口路径
String keyPrefix = "rate_limit:" + path.replaceAll("/", ":");
// 尝试获取令牌
boolean allowed = rateLimiter.tryAcquire(keyPrefix, rule.windowSizeMs, rule.bucketSizeMs, rule.threshold);
if (!allowed) {
log.warn("接口[{}]触发滑动窗口限流,拒绝请求", path);
// 返回限流响应
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = JSON.toJSONString(Result.error("请求过于频繁,请稍后再试"));
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body.getBytes())));
}
// 放行
return chain.filter(exchange);
}
// 限流规则内部类
@Data
@AllArgsConstructor
private static class RateLimitRule {
private long windowSizeMs;
private long bucketSizeMs;
private long threshold;
}
}
3.2 模块二:Sentinel热点参数限流(针对特定参数防刷)
滑动窗口是全局限流,现在我们加Sentinel热点参数限流,针对特定参数(比如员工ID、设备ID)限流。
第一步:配置Sentinel热点规则
我们可以在代码里配置,也可以在Sentinel控制台配置,推荐在控制台配置,动态调整不用重启服务。不过为了演示,我先在代码里配置:
@Configuration
public class SentinelConfig {
@PostConstruct
public void initHotspotRules() {
// 定义热点规则列表
List<ParamFlowRule> rules = new ArrayList<>();
// 考勤查询接口的热点规则:针对第0个参数(员工ID)限流
ParamFlowRule attendanceRule = new ParamFlowRule("attendanceQueryResource")
.setCount(10) // 单个员工ID每分钟10次
.setGrade(RuleConstant.FLOW_GRADE_QPS)
.setDurationInSec(60) // 窗口大小60秒
.setParamIdx(0); // 针对第0个参数限流
rules.add(attendanceRule);
// 设备数据上报接口的热点规则:针对第0个参数(设备ID)限流
ParamFlowRule deviceRule = new ParamFlowRule("deviceDataResource")
.setCount(30) // 单个设备ID每分钟30次(每2秒一次,正常上报频率)
.setGrade(RuleConstant.FLOW_GRADE_QPS)
.setDurationInSec(60)
.setParamIdx(0);
rules.add(deviceRule);
// 加载规则
ParamFlowRuleManager.loadRules(rules);
}
}
第二步:在业务服务里用@SentinelResource注解
注意:Sentinel热点参数限流要在业务服务里用(比如attendance-service、device-service),不是在网关里,因为网关里拿不到接口的参数(除非解析请求体,但解析请求体有性能损耗)。
在attendance-service的Controller里加注解:
@RestController
@RequestMapping("/api/attendance")
public class AttendanceController {
@Autowired
private AttendanceService attendanceService;
// 定义热点资源,参数索引0是employeeId
@SentinelResource(
value = "attendanceQueryResource",
blockHandler = "handleBlock",
fallback = "handleFallback"
)
@GetMapping("/query")
public Result<AttendanceVO> query(@RequestParam Long employeeId, @RequestParam String date) {
AttendanceVO vo = attendanceService.query(employeeId, date);
return Result.success(vo);
}
// 限流处理方法
public Result<AttendanceVO> handleBlock(Long employeeId, String date, BlockException ex) {
log.warn("员工ID[{}]触发热点参数限流", employeeId);
return Result.error("查询过于频繁,请稍后再试");
}
// 降级处理方法
public Result<AttendanceVO> handleFallback(Long employeeId, String date, Throwable ex) {
log.error("员工ID[{}]查询异常", employeeId, ex);
return Result.error("系统繁忙,请稍后再试");
}
}
3.3 模块三:其他恶意请求拦截(IP黑名单、User-Agent校验)
除了限流,我们还要加一些基础的恶意请求拦截:
IP黑名单拦截
@Component
@Order(-2) // 比滑动窗口限流优先级还高,先拦截黑名单
public class IpBlacklistFilter implements GlobalFilter {
private static final Set<String> BLACKLIST = new HashSet<>();
static {
// 可以从数据库或Redis加载黑名单,这里演示用静态集合
BLACKLIST.add("192.168.1.100");
BLACKLIST.add("10.0.0.50");
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
if (BLACKLIST.contains(ip)) {
log.warn("IP[{}]在黑名单中,拒绝请求", ip);
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = JSON.toJSONString(Result.error("您的IP已被禁止访问"));
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body.getBytes())));
}
return chain.filter(exchange);
}
}
User-Agent校验
@Component
@Order(-1)
public class UserAgentFilter implements GlobalFilter {
private static final Set<String> INVALID_USER_AGENTS = new HashSet<>();
static {
// 拦截明显的脚本User-Agent
INVALID_USER_AGENTS.add("python-requests");
INVALID_USER_AGENTS.add("curl");
INVALID_USER_AGENTS.add("wget");
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String userAgent = exchange.getRequest().getHeaders().getFirst("User-Agent");
if (userAgent == null) {
log.warn("User-Agent为空,拒绝请求");
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = JSON.toJSONString(Result.error("非法请求"));
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body.getBytes())));
}
for (String invalid : INVALID_USER_AGENTS) {
if (userAgent.toLowerCase().contains(invalid.toLowerCase())) {
log.warn("User-Agent[{}]非法,拒绝请求", userAgent);
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = JSON.toJSONString(Result.error("非法请求"));
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body.getBytes())));
}
}
return chain.filter(exchange);
}
}
四、踩过的坑:每一个都让我头大
1. Redis滑动窗口的Lua脚本KEYS命令性能问题
现象:一开始我在Lua脚本里用KEYS命令查找所有格子的键,Redis的KEYS命令是阻塞的,当限流key很多时,会导致Redis卡顿,正常请求也变慢。
原因:KEYS命令会遍历Redis的所有键,在生产环境中绝对不能用。
解决方法:不用KEYS命令,而是直接计算当前窗口内的所有格子键,然后用MGET命令获取,再统计。修改后的Lua脚本:
-- 优化后的滑动窗口Lua脚本,不用KEYS命令
local keyPrefix = KEYS[1]
local windowSize = tonumber(ARGV[1])
local bucketSize = tonumber(ARGV[2])
local threshold = tonumber(ARGV[3])
local currentTime = tonumber(ARGV[4])
local currentBucket = math.floor(currentTime / bucketSize)
local startBucket = currentBucket - math.floor(windowSize / bucketSize) + 1
-- 构造当前窗口内的所有格子键
local keys = {}
local bucket = startBucket
while bucket <= currentBucket do
table.insert(keys, keyPrefix .. ':' .. bucket)
bucket = bucket + 1
end
-- 用MGET获取所有格子的计数
local counts = redis.call('MGET', unpack(keys))
-- 统计总请求数
local total = 0
for _, count in ipairs(counts) do
if count then
total = total + tonumber(count)
end
end
if total >= threshold then
return 0
else
local currentKey = keyPrefix .. ':' .. currentBucket
redis.call('INCR', currentKey)
redis.call('PEXPIRE', currentKey, windowSize + bucketSize)
return 1
end
2. Sentinel热点参数限流的参数索引问题
现象:一开始我在@SentinelResource注解里设置paramIdx=0,但死活不生效,还是全局限流。
原因:Sentinel的热点参数限流,参数索引是从0开始的,但如果接口的参数是用@RequestBody传的对象,Sentinel默认不能直接解析对象里的字段,只能解析基本类型的参数。
解决方法:如果要解析对象里的字段,需要自定义ParamFlowItem,或者把要限流的字段单独提出来作为@RequestParam参数。我后来把employeeId单独提出来作为@RequestParam,就生效了。
3. 分布式环境下Sentinel规则不共享
现象:我们有两个API网关实例,在Sentinel控制台配置的规则,只有一个实例生效,另一个实例不生效。
原因:Sentinel默认是内存存储规则,多个实例之间不共享。
解决方法:用Sentinel的Nacos或Apollo配置中心,把规则持久化到配置中心,多个实例共享规则。我后来用了Nacos,配置很简单,官方文档有详细教程。
五、性能测试:数据说话,真的香
我用JMeter做了完整的压测,模拟恶意请求和正常请求混合的场景:
测试环境
- 服务器:2台戴尔PowerEdge R750,CPU Intel Xeon Silver 4310*2,内存32G,千兆局域网;
- 压测工具:JMeter 5.6,10台压测机;
- 测试场景:50%正常请求(员工打卡、查询),50%恶意请求(刷考勤查询、刷设备数据上报)。
测试结果(表格对比)
| 指标 | 防刷方案上线前 | 防刷方案上线后 | 提升幅度 |
|---|---|---|---|
| 恶意请求拦截率 | 10%(固定窗口) | 99% | 890%↑ |
| 正常请求平均响应时间 | 800ms(卡顿) | 120ms | 85%↓ |
| 高峰期CPU使用率 | 90% | 45% | 50%↓ |
| 高峰期内存占用 | 2.5G | 1.2G | 52%↓ |
| 正常请求成功率 | 60% | 99.9% | 66.5%↑ |
六、最后想说的话
一开始我觉得防刷就是加个简单的限流,真正被攻击了才发现:恶意请求的手段太多了,单一方案根本防不住。Redis滑动窗口+Sentinel热点参数限流+IP黑名单+User-Agent校验,多层防护,才能真正把恶意请求拦截在网关层。
现在我们的API网关已经稳定运行了一个月,再也没被恶意请求搞崩过,客户的IT部门终于不用天天加班处理故障了。大家如果在防刷过程中遇到滑动窗口原子性、Sentinel热点参数不生效、分布式规则共享的问题,欢迎在评论区交流,我会把我踩过的坑、解决办法毫无保留地分享给你们。
更多推荐


所有评论(0)