Redis ZSet+Lua脚本+SpringBoot实战:滑动窗口限流方案从原理到落地

我将以"Redis ZSet+Lua脚本+SpringBoot实战:滑动窗口限流方案从原理到落地"为标题,先拆解滑动窗口限流核心逻辑,再逐层讲清三者协同原理与实现步骤,附完整可复用代码,兼顾理论深度与实战性。

Redis ZSet+Lua脚本+SpringBoot实战:滑动窗口限流方案从原理到落地

在分布式系统中,接口限流是保障服务稳定性的关键手段,可有效防止恶意请求、流量峰值击垮服务。滑动窗口限流因精度高、灵活性强,成为主流限流方案之一。本文将结合Redis ZSet的有序特性、Lua脚本的原子性保障与SpringBoot的工程化能力,由浅入深拆解滑动窗口限流的实现原理,一步步完成落地开发,最终给出完整可运行代码,帮助大家吃透这一高频技术方案。

一、核心概念与原理拆解

在动手开发前,我们先理清核心概念,搞懂"为什么用Redis ZSet+Lua"实现滑动窗口限流,而非单纯依赖Java本地缓存。

1. 滑动窗口限流:比固定窗口更精准的限流方式

限流的核心是控制单位时间内的请求次数,常见方案分为固定窗口与滑动窗口:

  • 固定窗口限流:将时间划分为固定区间(如1分钟),统计每个区间内的请求数,超过阈值则拒绝。缺点是存在"边界漏洞",例如在两个相邻窗口的交界处(第59秒和第61秒),可能出现两倍阈值的请求,突破限流防线。

  • 滑动窗口限流:将固定窗口拆分为多个小时间片,窗口随时间推移逐片滑动,实时统计当前窗口内的请求数。例如1分钟窗口拆分为6个10秒时间片,每过10秒窗口滑动一次,仅统计最近1分钟内的请求,彻底解决边界漏洞,精度更高。

滑动窗口的核心需求的是:快速统计指定时间范围内的请求数,并保证统计与限流判断的原子性,这正是Redis ZSet与Lua脚本的优势所在。

2. Redis ZSet:滑动窗口的"时间容器"

ZSet的"member+score"结构天然适配滑动窗口场景,我们可以这样设计:

  • score字段:存储请求的时间戳(毫秒级),用于标记请求发生的时间点,借助ZSet的有序性快速筛选出"当前窗口内的请求"。

  • member字段:存储唯一标识(如UUID+请求IP),确保每个请求在ZSet中唯一,避免重复统计(也可直接用UUID,无需关联IP,视场景调整)。

核心逻辑:每次请求到来时,先删除ZSet中时间戳早于"当前时间-窗口时长"的元素(清理过期请求),再统计剩余元素数量(当前窗口内请求数)。若数量小于阈值,则允许请求并将当前请求存入ZSet;若大于等于阈值,则拒绝请求。

ZSet关键指令(后续实战核心):

  • ZADD key score member:存入请求时间戳与唯一标识;

  • ZREMRANGEBYSCORE key min max:删除时间戳在[min, max]区间外的过期请求;

  • ZCARD key:统计当前窗口内的请求数(剩余元素数量);

  • EXPIRE key seconds:给ZSet设置过期时间,避免长期存储无效数据。

3. Lua脚本:原子性保障的"关键锁"

滑动窗口限流的核心痛点是"统计请求数"与"存入新请求"的原子性。若分开执行ZREMRANGEBYSCORE、ZCARD、ZADD三个指令,高并发场景下可能出现以下问题:

请求A清理过期元素后,尚未统计与存入;请求B此时介入清理,导致统计结果失真,最终超出限流阈值。

Lua脚本可将多个Redis指令封装为一个原子操作------Redis执行Lua脚本时,会阻塞其他所有命令,直到脚本执行完成(需控制脚本执行时间在10ms内,避免阻塞Redis),彻底解决并发竞争问题。同时,Lua脚本支持条件判断,可直接在脚本中完成"清理-统计-判断-存储"全流程,减少网络交互开销。

4. SpringBoot:工程化落地的"载体"

SpringBoot通过Spring Data Redis快速整合Redis,提供便捷的API调用与Lua脚本执行能力;同时支持自定义注解+AOP,将限流逻辑与业务代码解耦,实现"注解式限流",开发者只需在需要限流的接口上添加注解,即可快速生效,大幅提升开发效率。

二、协同逻辑与实现步骤

本文以"接口每分钟最多允许10次请求"为实战需求,实现滑动窗口限流方案。整体流程:SpringBoot整合Redis环境 → 编写Lua脚本封装限流核心逻辑 → 自定义限流注解与AOP切面 → 业务接口集成限流功能 → 测试验证效果。

步骤1:SpringBoot整合Redis环境

1.1 引入依赖

在pom.xml中添加Spring Data Redis、Lettuce客户端、AOP(用于切面编程)、工具类依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lettuce客户端(SpringBoot 2.x+默认集成,显式声明版本更稳定)-->
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.2.6.RELEASE</version>
</dependency>
<!-- AOP依赖,用于实现注解式限流 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 工具类依赖,生成UUID、处理JSON -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.20</version>
</dependency>
1.2 配置Redis连接

在application.yml中配置Redis连接信息、序列化方式(避免存储乱码),同时添加限流相关自定义配置:

yaml 复制代码
spring:
  redis:
    host: localhost        # Redis服务地址
    port: 6379             # 端口
    password:              # 无密码则留空
    database: 0            # 数据库索引(默认0)
    lettuce:
      pool:
        max-active: 16     # 连接池最大活跃连接数
        max-idle: 8        # 连接池最大空闲连接数
        min-idle: 4        # 连接池最小空闲连接数
        max-wait: 3000ms   # 连接池最大阻塞等待时间

# 自定义限流配置
rate-limit:
  window-size: 60000      # 滑动窗口时长(毫秒),默认1分钟
  limit-count: 10         # 窗口内限流阈值,默认10次
  redis-key-prefix: rate:limit:  # 限流ZSet键前缀,避免key冲突
1.3 配置RedisTemplate与Lua脚本执行器

自定义RedisTemplate解决序列化问题,同时配置Lua脚本执行器,支持加载脚本文件并执行:

java 复制代码
package com.example.ratelimit.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.io.Serializable;

@Configuration
public class RedisConfig {

    /**
     * 自定义RedisTemplate,解决key/value序列化乱码问题
     */
    @Bean
    public RedisTemplate<String, Serializable> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Serializable&gt; redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);

        // Key序列化:String序列化器(避免key加前缀乱码)
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringSerializer);
        redisTemplate.setHashKeySerializer(stringSerializer);

        // Value序列化:Jackson序列化器(支持对象序列化)
        GenericJackson2JsonRedisSerializer jacksonSerializer = new GenericJackson2JsonRedisSerializer();
        redisTemplate.setValueSerializer(jacksonSerializer);
        redisTemplate.setHashValueSerializer(jacksonSerializer);

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

    /**
     * 配置Lua脚本执行器,指定返回值类型为Boolean(是否允许请求)
     */
    @Bean
    public DefaultRedisScript<Boolean> rateLimitRedisScript() {
        DefaultRedisScript<Boolean&gt; redisScript = new DefaultRedisScript<>();
        // 后续指定Lua脚本路径,此处先初始化
        redisScript.setResultType(Boolean.class);
        return redisScript;
    }
}

步骤2:编写Lua脚本封装限流核心逻辑

编写Lua脚本,实现"清理过期请求→统计请求数→判断是否允许请求→存入新请求"全流程原子操作。将脚本放在resources/lua目录下(路径:resources/lua/rateLimit.lua),便于SpringBoot加载。

lua 复制代码
-- 滑动窗口限流Lua脚本
-- 脚本参数说明:
-- KEYS[1]:限流ZSet的key(格式:前缀+接口标识+IP,确保粒度唯一)
-- ARGV[1]:当前请求时间戳(毫秒)
-- ARGV[2]:滑动窗口时长(毫秒)
-- ARGV[3]:窗口内限流阈值
-- ARGV[4]:请求唯一标识(UUID,避免重复统计)
-- ARGV[5]:ZSet过期时间(秒),防止无效数据堆积

local key = KEYS[1]
local currentTime = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])
local limitCount = tonumber(ARGV[3])
local requestId = ARGV[4]
local expireTime = tonumber(ARGV[5])

-- 1. 清理过期请求:删除时间戳早于(当前时间-窗口时长)的元素
local minScore = 0
local maxScore = currentTime - windowSize
redis.call('ZREMRANGEBYSCORE', key, minScore, maxScore)

-- 2. 统计当前窗口内的请求数
local currentCount = redis.call('ZCARD', key)

-- 3. 判断是否允许请求:请求数小于阈值则允许,否则拒绝
local allowed = false
if currentCount < limitCount then
    -- 3.1 允许请求,将当前请求存入ZSet(score=时间戳,member=唯一标识)
    redis.call('ZADD', key, currentTime, requestId)
    allowed = true
end

-- 4. 给ZSet设置过期时间,避免长期存储(过期时间略大于窗口时长,确保窗口内数据有效)
redis.call('EXPIRE', key, expireTime)

-- 5. 返回结果:true=允许请求,false=拒绝请求
return allowed

⚠️ 脚本优化点:设置ZSet过期时间(expireTime)时,需略大于窗口时长(如窗口60秒,过期时间设为70秒),避免窗口内数据被提前清理,导致统计失真。

步骤3:自定义限流注解与AOP切面

为了让限流逻辑与业务代码解耦,我们实现"注解式限流"------自定义@RateLimit注解,在需要限流的接口上添加注解,通过AOP切面拦截请求,执行Lua脚本完成限流判断。

3.1 自定义限流注解(RateLimit.java)

支持自定义每个接口的窗口时长、限流阈值,优先级高于全局配置:

java 复制代码
package com.example.ratelimit.annotation;

import java.lang.annotation.*;

/**
 * 自定义滑动窗口限流注解
 */
@Target({ElementType.METHOD})  // 仅作用于方法(接口)
@Retention(RetentionPolicy.RUNTIME)  // 运行时生效,允许反射获取
@Documented
public @interface RateLimit {

    /**
     * 滑动窗口时长(毫秒),默认取全局配置60000ms(1分钟)
     */
    long windowSize() default 0;

    /**
     * 窗口内限流阈值,默认取全局配置10次
     */
    int limitCount() default 0;

    /**
     * 限流提示信息
     */
    String message() default "请求过于频繁,请稍后再试!";
}
3.2 限流异常处理(RateLimitException.java)

自定义异常类,用于限流触发时抛出,便于全局捕获并返回友好提示:

java 复制代码
package com.example.ratelimit.exception;

import lombok.Getter;

/**
 * 限流异常类
 */
@Getter
public class RateLimitException extends RuntimeException {

    private final String message;

    public RateLimitException(String message) {
        this.message = message;
    }
}
3.3 全局异常处理器(GlobalExceptionHandler.java)

捕获限流异常,返回标准化JSON响应,避免直接抛出异常给前端:

java 复制代码
package com.example.ratelimit.handler;

import com.example.ratelimit.exception.RateLimitException;
import lombok.Data;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.servlet.http.HttpServletRequest;

/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 处理限流异常
     */
    @ExceptionHandler(RateLimitException.class)
    public ResultVO handleRateLimitException(RateLimitException e, HttpServletRequest request) {
        return new ResultVO(429, e.getMessage(), request.getRequestURI());
    }

    /**
     * 统一响应体
     */
    @Data
    public static class ResultVO {
        private int code;        // 状态码(429表示请求过于频繁)
        private String message;  // 提示信息
        private String path;     // 请求路径

        public ResultVO(int code, String message, String path) {
            this.code = code;
            this.message = message;
            this.path = path;
        }
    }
}
3.4 AOP切面实现限流逻辑(RateLimitAspect.java)

切面拦截带有@RateLimit注解的方法,获取请求IP、接口标识,组装参数调用Lua脚本,根据脚本返回结果判断是否允许请求:

java 复制代码
package com.example.ratelimit.aspect;

import cn.hutool.core.util.IdUtil;
import com.example.ratelimit.annotation.RateLimit;
import com.example.ratelimit.exception.RateLimitException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;

/**
 * 限流AOP切面
 */
@Aspect
@Component
public class RateLimitAspect {

    @Autowired
    private RedisTemplate<String, Serializable> redisTemplate;

    @Autowired
    private DefaultRedisScript<Boolean> rateLimitRedisScript;

    // 全局限流配置
    @Value("${rate-limit.window-size}")
    private long globalWindowSize;
    @Value("${rate-limit.limit-count}")
    private int globalLimitCount;
    @Value("${rate-limit.redis-key-prefix}")
    private String redisKeyPrefix;

    /**
     * 环绕通知,拦截带有@RateLimit注解的方法
     */
    @Around("@annotation(com.example.ratelimit.annotation.RateLimit)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 获取请求上下文、目标方法、限流注解
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RateLimit rateLimit = method.getAnnotation(RateLimit.class);

        // 2. 确定限流参数(接口注解配置优先级高于全局配置)
        long windowSize = rateLimit.windowSize() > 0 ? rateLimit.windowSize() : globalWindowSize;
        int limitCount = rateLimit.limitCount() > 0 ? rateLimit.limitCount() : globalLimitCount;
        String message = rateLimit.message();

        // 3. 组装限流ZSet的key:前缀 + 接口全路径 + 请求IP(按IP+接口粒度限流,避免单IP击穿)
        String interfaceName = method.getDeclaringClass().getName() + "." + method.getName();
        String clientIp = getClientIp(request);
        String redisKey = redisKeyPrefix + interfaceName + ":" + clientIp;

        // 4. 组装Lua脚本参数
        long currentTime = System.currentTimeMillis();  // 当前时间戳(毫秒)
        String requestId = IdUtil.fastUUID();          // 请求唯一标识(UUID)
        int expireTime = (int) (windowSize / 1000 + 10);  // ZSet过期时间(秒),比窗口时长多10秒

        List<String> keys = Collections.singletonList(redisKey);
        Object[] args = new Object[]{currentTime, windowSize, limitCount, requestId, expireTime};

        // 5. 加载并执行Lua脚本
        rateLimitRedisScript.setLocation(new ClassPathResource("lua/rateLimit.lua"));
        Boolean allowed = (Boolean) redisTemplate.execute(rateLimitRedisScript, keys, args);

        // 6. 判断是否允许请求:不允许则抛出限流异常
        if (Boolean.FALSE.equals(allowed)) {
            throw new RateLimitException(message);
        }

        // 7. 允许请求,执行目标方法
        return joinPoint.proceed();
    }

    /**
     * 获取客户端真实IP(兼容反向代理场景,如Nginx)
     */
    private String getClientIp(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();
        }
        // 处理多代理场景,取第一个非unknown的IP
        if (ip != null && ip.contains(",")) {
            ip = ip.split(",")[0].trim();
        }
        return ip;
    }
}

步骤4:业务接口集成限流功能

创建测试接口,在接口上添加@RateLimit注解,可自定义窗口时长与限流阈值,验证限流效果:

java 复制代码
package com.example.ratelimit.controller;

import com.example.ratelimit.annotation.RateLimit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/test")
public class TestController {

    /**
     * 测试接口1:使用全局限流配置(1分钟10次请求)
     */
    @GetMapping("/demo1")
    @RateLimit(message = "demo1接口请求过于频繁,请1分钟后再试!")
    public Map<String, Object> demo1() {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("message", "请求成功");
        result.put("data", "demo1接口响应内容");
        return result;
    }

    /**
     * 测试接口2:自定义限流配置(30秒5次请求)
     */
    @GetMapping("/demo2")
    @RateLimit(windowSize = 30000, limitCount = 5, message = "demo2接口请求过于频繁,请30秒后再试!")
    public Map<String, Object> demo2() {
        Map<String, Object&gt; result = new HashMap<>();
        result.put("code", 200);
        result.put("message", "请求成功");
        result.put("data", "demo2接口响应内容");
        return result;
    }
}

三、完整代码结构与测试验证

3.1 项目结构

3.2 测试验证

启动SpringBoot项目,通过Postman或浏览器频繁访问测试接口,验证限流效果:

  1. 测试demo1接口:1分钟内连续请求11次,前10次返回成功响应(code=200),第11次返回限流提示(code=429,提示"demo1接口请求过于频繁...");等待1分钟后,可再次正常请求。

  2. 测试demo2接口:30秒内连续请求6次,前5次成功,第6次触发限流;等待30秒后,请求恢复正常。

  3. 多IP测试:用不同IP访问同一接口,各自独立统计请求数,互不影响,验证"IP+接口"粒度的限流效果。

同时可通过Redis客户端查看ZSet数据:执行ZRANGE rate:limit:com.example.ratelimit.controller.TestController.demo1:127.0.0.1 0 -1 WITHSCORES,可看到窗口内的请求时间戳与唯一标识,过期请求会被自动清理。

四、进阶优化与注意事项

1. 性能优化

  • Lua脚本缓存 :当前实现每次请求都会加载Lua脚本文件,可缓存脚本的SHA1值,通过redis.call('EVALSHA', sha1, keyCount, keys, args)执行脚本,减少文件IO开销。

  • Redis连接池优化:根据并发量调整连接池参数(max-active、max-wait等),避免连接池耗尽导致请求阻塞。

  • key粒度优化:除了"IP+接口"粒度,还可根据业务需求调整为"用户ID+接口""APPID+接口"等粒度,适配不同场景。

2. 注意事项

  • Lua脚本执行时间:必须控制在10ms内,避免阻塞Redis主线程。本方案中仅涉及3个核心Redis指令,执行效率极高,可放心使用。

  • Redis高可用:限流依赖Redis可用性,生产环境需部署Redis集群(主从+哨兵或Redis Cluster),避免单点故障导致限流失效。

  • 时间同步问题:滑动窗口依赖时间戳,需确保Redis服务器与应用服务器时间同步,否则可能出现统计偏差。

  • 大量过期key清理:虽然ZSet设置了过期时间,但大量限流key可能导致Redis主动清理压力增大,可结合定时任务批量清理无效key。

五、总结

本文基于Redis ZSet的有序特性、Lua脚本的原子性与SpringBoot的工程化能力,实现了高精度、高可用的滑动窗口限流方案。核心逻辑是通过ZSet维护请求时间戳,Lua脚本保障"清理-统计-判断-存储"原子性,AOP+自定义注解实现与业务代码解耦。该方案适用于分布式系统的接口限流场景,可根据业务需求灵活调整窗口时长、限流阈值与key粒度。

相比其他限流方案(如Guava RateLimiter),本方案基于Redis实现,支持分布式环境下的统一限流,避免单机限流的局限性;同时滑动窗口精度高于固定窗口,Lua脚本解决了并发安全问题,是生产环境的优选方案。

相关推荐
是梦终空2 小时前
计算机毕业设计263—基于Springboot+Vue的影视推荐和评分系统(源代码+数据库)
spring boot·vue·毕业设计·课程设计·协同过滤算法·影评系统·影视推荐系统
一 乐2 小时前
在线考试|基于springboot + vue在线考试系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·课程设计
qq_12498707532 小时前
基于spring boot的调查问卷系统的设计与实现(源码+论文+部署+安装)
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
醒过来摸鱼3 小时前
redis源码deps目录
数据库·redis·缓存
Huanlis3 小时前
Redis Stream 核心原理与实战指南
数据库·redis·缓存
让我上个超影吧3 小时前
SpringAI会话记忆实现——基于MYSQL进行存储
java·spring boot·ai
Geoking.3 小时前
【Redis】Redis 中的 Pipeline 与 Lua 脚本:高性能与原子性的两种武器
redis·lua
小北方城市网14 小时前
Redis 分布式锁高可用实现:从原理到生产级落地
java·前端·javascript·spring boot·redis·分布式·wpf
毕设源码-钟学长16 小时前
【开题答辩全过程】以 基于SpringBoot的智能书城推荐系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端