SpringBoot 整合 Redis 实现分布式限流——防止接口被刷

线上服务最怕被恶意刷接口或者突发流量把系统打垮。单机限流用 Guava RateLimiter 就行,但微服务多实例部署时必须用分布式限流------所有实例共享同一个限流状态。

一、限流的常见算法

算法 原理 特点
固定窗口 1 分钟内最多 N 个请求 简单但有突刺问题
滑动窗口 把窗口切小段,更平滑 推荐,较常用
漏桶 请求进桶,匀速流出 平滑流量,但突发处理慢
令牌桶 匀速放令牌,拿到的才通过 允许一定突发,最均衡

Redis 实现限流的本质: 用 Redis 计数器/有序集合记录请求次数,加上过期时间自动清理。

二、固定窗口限流

最简单的方案:一个 key 记录 1 分钟内的请求数。

java 复制代码
@Component
public class FixedWindowRateLimiter {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 固定窗口限流
     * @param key   限流 key(如 "rate:user:1001")
     * @param max    窗口内最大请求数
     * @param window 窗口大小(秒)
     * @return true 通过,false 被限
     */
    public boolean tryAcquire(String key, long max, long window) {
        // INCR 原子自增
        Long count = redisTemplate.opsForValue().increment(key);
        if (count == 1) {
            // 第一次访问,设置过期时间
            redisTemplate.expire(key, window, TimeUnit.SECONDS);
        }
        return count <= max;
    }
}
java 复制代码
@RestController
public class TestController {

    @Autowired
    private FixedWindowRateLimiter rateLimiter;

    @GetMapping("/api/test")
    public ResultVO<?> test() {
        // 限制每个用户每秒最多 5 次
        if (!rateLimiter.tryAcquire("rate:user:1001", 5, 1)) {
            return ResultVO.error(429, "请求太频繁,请稍后");
        }
        return ResultVO.success("成功");
    }
}

问题: 窗口边界会有突刺------比如第 59 秒和第 61 秒各发 5 个请求,理论上通过 10 个,但看起来像 1 秒内 10 个。

三、滑动窗口限流(推荐)

用 Redis ZSet(有序集合)记录每个请求的时间戳,滑动窗口更精确。

java 复制代码
@Component
public class SlidingWindowRateLimiter {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 滑动窗口限流
     * @param key      限流 key
     * @param max      窗口内最大请求数
     * @param window   窗口大小(毫秒)
     * @return true 通过,false 被限
     */
    public boolean tryAcquire(String key, long max, long windowMs) {
        long now = System.currentTimeMillis();
        long windowStart = now - windowMs;

        // 使用 Lua 脚本保证原子性
        String luaScript =
            "redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) " +    // 移除窗口外的记录
            "local count = redis.call('ZCARD', KEYS[1]) " +             // 统计窗口内请求数
            "if count < tonumber(ARGV[2]) then " +
            "  redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3]) " +        // 加入当前请求
            "  redis.call('EXPIRE', KEYS[1], ARGV[4]) " +               // 设置过期(秒)
            "  return 1 " +
            "else " +
            "  return 0 " +
            "end";

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(key),
            String.valueOf(windowStart),
            String.valueOf(max),
            String.valueOf(now),
            String.valueOf(windowMs / 1000 + 1)
        );

        return Long.valueOf(1).equals(result);
    }
}

四、注解式限流

把限流逻辑封装成注解,用在需要的接口上,代码更干净。

1. 定义注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {

    /** 限流 key(支持 SpEL) */
    String key() default "";

    /** 限流维度:用户、IP、接口 */
    String type() default "user";  // user、ip、api

    /** 最大请求数 */
    long max() default 10;

    /** 窗口大小(秒) */
    long window() default 1;
}

2. 实现 AOP 切面

java 复制代码
@Aspect
@Component
public class RateLimitAspect {

    @Autowired
    private SlidingWindowRateLimiter rateLimiter;

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
        // 构建 key
        String key = buildKey(rateLimit, pjp);

        // 执行限流
        boolean acquired = rateLimiter.tryAcquire(
                key, rateLimit.max(), rateLimit.window() * 1000);

        if (!acquired) {
            throw new RuntimeException("请求太频繁,请稍后重试");
        }

        return pjp.proceed();
    }

    private String buildKey(RateLimit rateLimit, ProceedingJoinPoint pjp) {
        String prefix = "rate:";

        switch (rateLimit.type()) {
            case "ip":
                // 从请求中获取 IP(需要注入 RequestContextHolder)
                HttpServletRequest request = ((ServletRequestAttributes)
                    RequestContextHolder.getRequestAttributes()).getRequest();
                String ip = request.getRemoteAddr();
                return prefix + "ip:" + ip;

            case "api":
                // 按接口限流
                String methodName = pjp.getSignature().toShortString();
                return prefix + "api:" + methodName;

            default:
                // 按用户限流(需要从 Token 获取用户 ID)
                String userId = StpUtil.getLoginIdAsString();
                return prefix + "user:" + userId;
        }
    }
}

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

    @ExceptionHandler(RuntimeException.class)
    public ResultVO<?> handle(RuntimeException e) {
        if (e.getMessage().contains("请求太频繁")) {
            return ResultVO.error(429, "请求太频繁,请稍后重试");
        }
        return ResultVO.error(500, e.getMessage());
    }
}

3. 使用

java 复制代码
@RestController
@RequestMapping("/api")
public class OrderController {

    @PostMapping("/order")
    @RateLimit(key = "createOrder", type = "user", max = 5, window = 10)
    public ResultVO<?> createOrder(@RequestBody OrderDTO dto) {
        // 每个用户 10 秒内最多 5 次下单
        return ResultVO.success("下单成功");
    }

    @GetMapping("/product/list")
    @RateLimit(type = "api", max = 100, window = 1)
    public ResultVO<?> list() {
        // 接口级别限流:每秒最多 100 次
        return ResultVO.success("商品列表");
    }
}

五、秒杀系统的限流

在秒杀系统中,限流是保护系统的第一道防线:

java 复制代码
@RestController
@RequestMapping("/api/seckill")
public class SeckillController {

    @Autowired
    private SeckillService seckillService;

    @PostMapping("/do/{productId}")
    @RateLimit(type = "user", max = 1, window = 10)    // 每个用户10秒只能秒杀1次
    public ResultVO<?> doSeckill(@PathVariable Long productId) {
        long userId = StpUtil.getLoginIdAsLong();
        return seckillService.doSeckill(productId, userId, "用户");
    }
}

秒杀系统的多层防护:

复制代码
用户请求
  ↓
① 限流(Redis 滑动窗口)→ 被限返回"操作频繁"
  ↓
② 参数校验 → 不合法直接返回
  ↓
③ 时间校验 → 没开始/已结束直接返回
  ↓
④ 重复提交校验(SETNX)→ 已提交直接返回
  ↓
⑤ Redis 预减库存 → 库存不足直接返回
  ↓
⑥ 数据库扣库存(乐观锁)→ 失败回滚
  ↓
⑦ 生成订单

限流在最外层,把大部分无效请求挡在门外。

六、IP 限流

java 复制代码
@GetMapping("/api/public")
@RateLimit(type = "ip", max = 30, window = 60)
public ResultVO<?> publicApi() {
    // 同一 IP 每分钟最多 30 次
    return ResultVO.success("公开接口");
}

获取真实 IP(经过 Nginx 代理时):

java 复制代码
public static String getRealIp(HttpServletRequest request) {
    String ip = request.getHeader("X-Forwarded-For");
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getHeader("X-Real-IP");
    }
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getRemoteAddr();
    }
    // 多个代理时取第一个
    if (ip != null && ip.contains(",")) {
        ip = ip.split(",")[0].trim();
    }
    return ip;
}

七、限流配置管理

把限流参数放在配置中心或 yml 中,不用改代码:

yaml 复制代码
rate-limit:
  defaults:
    max: 10
    window: 1
  rules:
    - key: "createOrder"
      max: 5
      window: 10
    - key: "seckill"
      max: 1
      window: 10
    - key: "publicApi"
      max: 30
      window: 60
java 复制代码
@ConfigurationProperties(prefix = "rate-limit")
@Data
public class RateLimitProperties {
    private Rule defaults = new Rule();
    private List<Rule> rules = new ArrayList<>();

    @Data
    public static class Rule {
        private String key;
        private long max;
        private long window;
    }
}

八、限流效果验证

bash 复制代码
# 快速请求测试限流效果
for i in {1..20}; do
  curl -X POST http://localhost:9090/api/order
  echo ""
done

# 正常返回:{"code":200,"message":"下单成功"}
# 被限返回:{"code":429,"message":"请求太频繁,请稍后重试"}

九、限流 vs 熔断 vs 降级

限流 Ratelimit 熔断 CircuitBreaker 降级 Degrade
作用 控制请求速率 防止故障扩散 舍弃非核心功能
时机 请求进来时 错误率超标时 资源不足时
工具 Redis + Lua Sentinel、Resilience4j Sentinel、Hystrix

建议: 限流是必须的,熔断和降级在大流量的核心系统上建议加上。


💡 觉得有用的话,点赞 + 关注【张老师技术栈】吧!每周更新 Java/Python/爬虫 实战干货,不让你白来。