上个月我们的智能监控系统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脚本的逻辑很清晰:

  1. 计算当前时间所在的格子和窗口的起始格子;
  2. 清理窗口外的过期格子,防止Redis内存泄漏;
  3. 统计当前窗口内的总请求数;
  4. 如果超过阈值,返回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热点参数不生效、分布式规则共享的问题,欢迎在评论区交流,我会把我踩过的坑、解决办法毫无保留地分享给你们。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐