springboot+redis+lua脚本实现滑动窗口限流

1 限流

为了维护系统稳定性和防止DDoS攻击,需要对系统请求量进行限制。

2 滑动窗口

限流方式有:固定窗口,滑动窗口,令牌桶和漏斗。滑动窗口的意思是:维护一个长度固定的窗口,动态统计窗口内请求次数,如果窗口内请求次数超过阈值则不允许访问。

3 实现

参考https://www.jianshu.com/p/cb11e552505b。采用Redis的zset数据结构,将当前请求的时间戳作为score字段,统计窗口时间内请求次数是否超过限制。

完整代码在https://gitcode.com/zsss1/ratelimit/overview

java 复制代码
// 限流类型
public enum LimitType {
    DEFAULT,
    IP
}
java 复制代码
// 限流注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
    String key() default "rate:limiter:";
    long limit() default 1;
    long expire() default 1;
    String message() default "访问频繁";
    LimitType limitType() default LimitType.IP;
}
java 复制代码
// 限流切面
@Component
@Aspect
public class RateLimiterHandler {
    private static final Logger LOGGER = LoggerFactory.getLogger(RateLimiterHandler.class);

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    @Qualifier("sliding_window")
    private RedisScript<Long> redisScript;

	// AOP动态代理com.example包下所有@annotation注解的方法
    @Around("execution(* com.example..*.*(..)) && @annotation(rateLimiter)")
    public Object around(ProceedingJoinPoint proceedingJoinPoint, RateLimiter rateLimiter) throws Throwable {
        Object[] args = proceedingJoinPoint.getArgs();
        long currentTime = Long.parseLong((String) args[0]);
        MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
        Method method = signature.getMethod();
        StringBuilder limitKey = new StringBuilder(rateLimiter.key());
        if (rateLimiter.limitType() == LimitType.IP) {
            limitKey.append("127.0.0.1");
        }
        String className = method.getDeclaringClass().getName();
        String methodName = method.getName();
        limitKey.append("_").append(className).append("_").append(methodName);
        long limitCount = rateLimiter.limit();
        long windowTime = rateLimiter.expire();

        List<String> keyList = new ArrayList<>();
        keyList.add(limitKey.toString());
        Long result = redisTemplate.execute(redisScript, keyList, windowTime, currentTime, limitCount);

        if (result != null && result != 1) {
            throw new RuntimeException(rateLimiter.message());
        }
        return proceedingJoinPoint.proceed();
    }
}
lua 复制代码
-- 如果允许本次请求,返回1;如果不允许本次请求,返回0
--获取KEY

local key = KEYS[1]

--获取ARGV内的参数

-- 缓存时间

local expire = tonumber(ARGV[1])

-- 当前时间

local currentMs = tonumber(ARGV[2])

-- 最大次数

local limit_count = tonumber(ARGV[3])

--窗口开始时间

local windowStartMs = currentMs - tonumber(expire * 1000)

--获取key的次数

local current = redis.call('zcount', key, windowStartMs, currentMs)

--如果key的次数存在且大于预设值直接返回当前key的次数

if current and tonumber(current) >= limit_count then
    return 0;
end

-- 清除所有过期成员

redis.call("ZREMRANGEBYSCORE", key, 0, windowStartMs);

-- 添加当前成员

redis.call("zadd", key, currentMs, currentMs);

redis.call("expire", key, expire);

--返回key的次数

return 1
java 复制代码
// 测试类
// 为了方便统计当前时间,将时间作为请求参数传入接口
@SpringBootTest(classes = DemoApplication.class)
@AutoConfigureMockMvc
public class RateLimitControllerTest {
    @Autowired
    private WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;
    @BeforeEach
    public void setUp() throws Exception {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
    }

    @Test
    public void test_rate_limit() throws Exception {
        String url = "/rate/test";
        Map<Long, Integer> timeStatusMap = new LinkedHashMap<>();
        
        for (int i = 0; i < 20; i++) {
            Thread.sleep(800);
            long currentTime = System.currentTimeMillis();
            MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(url)
                    .param("currentTime", String.valueOf(currentTime)).accept(MediaType.APPLICATION_JSON);
            int status = mockMvc.perform(builder).andReturn().getResponse().getStatus();
            timeStatusMap.put(currentTime, status);
        }

        for (Map.Entry<Long, Integer> entry : timeStatusMap.entrySet()) {
            Long currentTime = entry.getKey();
            int status = entry.getValue();
            int spectedStatus = getStatusOfCurrentTime(currentTime, timeStatusMap.entrySet());
            System.out.println(status + ", " + spectedStatus + ", " + currentTime);
            // assertEquals(status, spectedStatus);
        }
    }

    private int getStatusOfCurrentTime(Long currentTime, Set<Map.Entry<Long, Integer>> set) {
        long startTime = currentTime - 5000;
        int count = 0;
        for (Map.Entry<Long, Integer> entry : set) {
            if (entry.getKey() >= startTime && entry.getKey() < currentTime && entry.getValue() == 200) {
                count++;
            }
        }
        if (count < 5) {
            return 200;
        }
        return 400;
    }
}
java 复制代码
// 接口
@RestController
@RequestMapping("/rate")
public class RateLimitController {
    @GetMapping("/test")
    @RateLimiter(limit = 5, expire = 5, limitType = LimitType.IP)
    public String test(String currentTime) {
        return "h";
    }
}
相关推荐
CopyLower12 分钟前
在 Spring Boot 中实现 WebSockets
spring boot·后端·iphone
.生产的驴1 小时前
SpringBoot 封装统一API返回格式对象 标准化开发 请求封装 统一格式处理
java·数据库·spring boot·后端·spring·eclipse·maven
晨集1 小时前
Uni-App 多端电子合同开源项目介绍
java·spring boot·uni-app·电子合同
AnsenZhu1 小时前
2025年Redis分片存储性能优化指南
数据库·redis·性能优化·分片
时间之城1 小时前
笔记:记一次使用EasyExcel重写convertToExcelData方法无法读取@ExcelDictFormat注解的问题(已解决)
java·spring boot·笔记·spring·excel
李菠菜3 小时前
POST请求的三种编码及SpringBoot处理详解
spring boot·后端
李菠菜3 小时前
非SpringBoot环境下Jedis集群操作Redis实战指南
java·redis
我的golang之路果然有问题3 小时前
快速了解redis,个人笔记
数据库·经验分享·redis·笔记·学习·缓存·内存
道友老李4 小时前
【存储中间件】Redis核心技术与实战(五):Redis缓存使用问题(BigKey、数据倾斜、Redis脑裂、多级缓存)、互联网大厂中的Redis
redis·缓存·中间件
斜月4 小时前
Springboot wechatpay-java 微信支付实践
spring boot·后端