一、前言

在高并发场景下,服务限流是保障系统稳定性和可用性的关键手段。常见的限流算法有计数器、固定窗口、滑动窗口、令牌桶及漏桶算法等。

本文将重点介绍如何利用 Redisson + 自定义 Lua 脚本,结合 AOP(面向切面编程)和 自定义注解,来构建一个灵活、易用且支持分布式环境的限流解决方案。该方案支持多维度限流,并完美兼容 Redis Cluster 模式。

二、整体架构设计

本项目结合 AOP 和自定义注解来实现基于 Redisson 的分布式限流,是业界广泛采用且推荐的一种模式。其优势在于:

  1. 关注点分离 (AOP): 限流逻辑本质上与核心业务逻辑解耦,属于典型的横切关注点。AOP 允许我们将限流的通用逻辑从业务方法中抽离出来,集中到一个切面类中管理。
  2. 声明式使用 (Annotation): 通过定义 @RateLimit 注解,开发者只需在需要限流的方法上添加注解并配置参数即可,极大降低了侵入性。
  3. 分布式支持: 利用 Redis 作为中心存储,通过 Lua 脚本保证操作的原子性。
  4. Redis Cluster 兼容: 通过 Hash Tag 确保所有限流 Key 落在同一个 Slot。

系统交互架构图如下:

┌─────────────────┐     ┌─────────────────┐       ┌─────────────────┐
│   Controller    │────▶│  @RateLimit     │────▶│ RateLimitAspect │
│   接口层         │     │  注解标记        │      │  AOP 切面拦截   │
└─────────────────┘     └─────────────────┘       └────────┬────────┘
                                                           │
                            ┌──────────────────────────────┘
                            │
                            ▼
                    ┌─────────────────┐                              
                    │  Lua 脚本执行    │
                    │  滑动窗口计数    │
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │     Redis       │
                    │   StringRedis   │
                    └─────────────────┘

三、环境准备与依赖配置

本项目使用 Redisson 作为 Redis 客户端。首先,我们需要在项目中引入相关依赖并配置 Redis 连接。

3.1 添加 Maven 依赖

pom.xml 中添加 Redisson 的 Spring Boot Starter:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.17.5</version> <!-- 请根据实际情况使用最新版本 -->
</dependency>

3.2 配置 application.yml

# application.yml
spring:
  redis:
    redisson:
      config: |
        singleServerConfig:
          address: "redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}"
          database: 0
          connectionMinimumIdleSize: 10
          connectionPoolSize: 64
          subscriptionConnectionMinimumIdleSize: 1
          subscriptionConnectionPoolSize: 50

四、核心代码实现

4.1 自定义限流注解 @RateLimiter

通过自定义注解,我们可以非常方便地为方法添加限流能力。注解包含限流 key 前缀、时间窗口大小、窗口内允许的请求数、限流维度等参数。

参数说明:
windowlimit 这两个参数直接决定了系统的 QPS。例如:设置 window=1 秒, limit=100,则 QPS 最高是 100;设置 window=0.5 秒, limit=100,则 QPS 最高是 200。

package com.hmdp.limiter.annotation;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {

    /**
     * 限流key前缀
     */
    String key() default "rate_limit:";

    /**
     * 时间窗口大小(秒)
     */
    int window() default 10;

    /**
     * 时间窗口内允许的请求数
     */
    int limit() default 20;

    /**
     * 限流提示信息
     */
    String message() default "系统繁忙,请稍后再试";

    /**
     * 限流维度(默认按方法限流)
     */
    LimitType type() default LimitType.METHOD;

    enum LimitType {
        /** 按调用方IP限流 */
        IP,
        /** 按用户ID限流 */
        USER,
        /** 按方法限流/全局限流(默认) */
        METHOD
    }
}

4.2 限流切面处理器 RateLimiterAspect

这是限流逻辑的核心,AOP 切面会拦截所有标注了 @RateLimiter 的方法,并执行限流脚本。

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
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;

@Aspect
@Component
public class RateLimiterAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    // Lua 脚本将在下一小节单独加载

    /**
     * 执行滑动窗口限流脚本
     *
     * @param key 限流key
     * @param window 时间窗口(秒)
     * @param limit 限制请求数量
     * @return 当前窗口内请求数量计数(如果被限流返回0)
     */
    public Long executeSlidingWindowScript(String key, Long window, Long limit) {
        long now = System.currentTimeMillis();
        System.out.printf("key:%s, window:%d, limit:%d\n", key, window, limit);
        // 此处假设 SLIDING_WINDOW_SCRIPT 是已加载的 Lua 脚本对象
        return stringRedisTemplate.execute(
                SLIDING_WINDOW_SCRIPT,
                Collections.singletonList(key),
                window.toString(), limit.toString(), Long.toString(now)
        );
    }

    /**
     * 构建限流key
     */
    private String buildRateLimitKey(JoinPoint point, RateLimiter rateLimiter, String baseKey) {
        StringBuilder keyBuilder = new StringBuilder(baseKey);

        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();

        // 添加类名和方法名
        keyBuilder.append(method.getDeclaringClass().getName())
                .append(":")
                .append(method.getName());

        // 根据限流类型添加额外维度
        switch (rateLimiter.type()) {
            case IP:
                keyBuilder.append(":ip:").append(getClientIp());
                break;
            case USER:
                keyBuilder.append(":user:").append(getCurrentUserId());
                break;
            case METHOD:
            default:
                // 方法级限流使用默认key
                break;
        }

        return keyBuilder.toString();
    }

    /**
     * 获取客户端IP
     */
    private String getClientIp() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        // 对于多级代理,取第一个非unknown的IP
        if (ip != null && ip.contains(",")) {
            ip = ip.split(",")[0].trim();
        }
        return ip;
    }

    /**
     * 获取当前用户ID(需要根据实际系统实现)
     */
    private String getCurrentUserId() {
        // 这里需要根据你的认证系统实现
        // 例如从SecurityContext获取认证用户
        return "anonymous"; // 默认返回匿名用户
    }
}

4.3 核心:滑动窗口限流 Lua 脚本

这是整个限流方案的灵魂,通过 Lua 脚本在 Redis 服务端原子性地完成 “回收过期令牌 -> 检查配额 -> 扣减令牌” 三步操作。

-- 单 key 原子限流脚本
-- 基于滑动时间窗口,单次调用完成:回收过期令牌 → 检查配额 → 扣减

-- 参数说明:
-- KEYS[1]: 限流维度键(单个)
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 申请令牌数
-- ARGV[3]: 时间窗口(毫秒)
-- ARGV[4]: 最大令牌数(窗口内允许的总数)
-- ARGV[5]: 请求唯一标识

local key = KEYS[1]
local now_ms = tonumber(ARGV[1])
local permits = tonumber(ARGV[2])
local interval = tonumber(ARGV[3])
local max_tokens = tonumber(ARGV[4])
local request_id = ARGV[5]

local value_key = key .. ":value"
local permits_key = key .. ":permits"

-- 1. 初始化 value_key(如果不存在)
if redis.call("exists", value_key) == 0 then
    redis.call("set", value_key, max_tokens)
end

-- 2. 回收过期令牌
-- 清理过期的 permit 记录,并回收配额到 value_key
local expired_values = redis.call("zrangebyscore", permits_key, 0, now_ms - interval)
if #expired_values > 0 then
    local expired_count = 0
    for _, v in ipairs(expired_values) do
        local p = tonumber(string.match(v, ":(%d+)$"))
        if p then
            expired_count = expired_count + p
        end
    end

    -- 删除过期记录
    redis.call("zremrangebyscore", permits_key, 0, now_ms - interval)

    -- 回收配额
    if expired_count > 0 then
        local curr_v = tonumber(redis.call("get", value_key) or max_tokens)
        local next_v = math.min(max_tokens, curr_v + expired_count)
        redis.call("set", value_key, next_v)
    end
end

-- 3. 检查配额
local current_val = tonumber(redis.call("get", value_key) or max_tokens)
if current_val < permits then
    return 0 -- 限流
end

-- 4. 扣减令牌
-- 记录本次令牌分配(格式:request_id:permits)
local permit_record = request_id .. ":" .. permits
redis.call("zadd", permits_key, now_ms, permit_record)

-- 扣减
local current_v = tonumber(redis.call("get", value_key) or max_tokens)
redis.call("set", value_key, current_v - permits)

-- 5. 设置过期时间,确保过期令牌能被正常回收 (窗口的2倍,至少1秒)
local expire_time = math.ceil(interval * 2 / 1000)
if expire_time < 1 then expire_time = 1 end
redis.call("expire", value_key, expire_time)
redis.call("expire", permits_key, expire_time)

return 1 -- 通过

脚本逻辑图解:

  1. 初始化: 如果 value_key 不存在,则初始化为最大令牌数。
  2. 回收: 从有序集合 permits_key 中找出所有已经超出时间窗口的过期令牌记录,累加它们的数量,将其加回到 value_key 中,并删除这些过期记录。
  3. 判断: 检查 value_key 中的当前可用令牌数是否满足本次请求的 permits
  4. 通过/拒绝: 如果满足,则记录本次申请并扣减令牌,返回 1;否则返回 0。

请求进入

ZREMRANGEBYSCORE:
清理窗口外数据
scope < now - window

ZCARD:
统计当前窗口内请求数

count < limit?

ZADD:
记录本次请求时间戳

PEXPIRE:
设置 Key 过期时间

return 1: 放行

return 0: 拒绝

五、使用示例

5.1 基础用法

在 Controller 的方法上添加 @RateLimit 注解即可实现多维度限流。

@PostMapping("/api/resumes/upload")
@RateLimit(dimension = Dimension.GLOBAL, count = 100) // 全局每秒100次
@RateLimit(dimension = Dimension.IP, count = 5)       // 单IP每秒5次
public Result<Map<String, Object>> uploadAndAnalyze(@RequestParam("file") MultipartFile file) {
    // 业务逻辑
    Map<String, Object> result = uploadService.uploadAndAnalyze(file);
    return Result.success(result);
}

关键点: 上述配置中,两条 @RateLimit 规则是独立检查的。任意一条规则被拒绝,整个请求就会被拦截。这彻底解决了旧方案中多维度共享同一参数导致限流失效的问题。

5.2 灵活的时间单位配置

// 每分钟 100 次
@RateLimit(count = 100, interval = 1, timeUnit = TimeUnit.MINUTES)

// 每小时 1000 次
@RateLimit(count = 1000, interval = 1, timeUnit = TimeUnit.HOURS)

// 每 500 毫秒 1 次
@RateLimit(count = 1, interval = 500, timeUnit = TimeUnit.MILLISECONDS)

六、方案对比:自定义 Lua vs Redisson RRateLimiter

特性 自定义 Lua 实现 Redisson RRateLimiter
多维度支持 通过 @Repeatable 多注解,每条规则拥有独立参数,天然支持 需要多次调用,开发工作量大
Redis Cluster 兼容 通过 Hash Tag 自动适配,实现简单 需要额外处理,容易出错
定制化程度 完全可控,可深度定制流量塑形等高级功能 依赖 Redisson 内部实现,灵活性受限
实现复杂度 需要编写、维护 Lua 脚本 开箱即用,API 简单
性能 SHA 预加载优化,单 key 脚本简洁高效 官方预编译脚本,性能同样出色

选型建议:
如果项目只需要简单的单维度限流且不使用 Redis Cluster,直接使用 RRateLimiter 是更简单的选择。

RRateLimiter rateLimiter = redisson.getRateLimiter("myLimiter");
rateLimiter.trySetRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);
if (rateLimiter.tryAcquire(1)) {
    // 允许请求
}

七、测试验证

编写单元测试,验证限流逻辑的正确性。

@Test
@DisplayName("验证限流:令牌充足时允许,耗尽时拒绝")
void testRateLimit() {
    // 初始化 2 个令牌
    redissonClient.getBucket(valueKey, StringCodec.INSTANCE).set("2");

    // 前两次成功
    assertEquals(1L, executeLuaScript(keyPrefix, maxCount));
    assertEquals(1L, executeLuaScript(keyPrefix, maxCount));

    // 第三次被拒绝
    assertEquals(0L, executeLuaScript(keyPrefix, maxCount));
}

八、总结

本文详细介绍了一套基于 Redisson 和自定义 Lua 脚本的分布式限流方案。其核心组件如下:

组件 说明
@RateLimit 限流注解,支持多维度、多时间单位配置
RateLimitAspect AOP 切面,拦截注解并执行限流逻辑
Lua 脚本 原子化执行滑动窗口限流算法,支持令牌回收
Redisson 高性能 Redis 客户端,提供脚本执行能力

技术亮点回顾:

  1. 灵活的时间窗口:支持秒/分/时/天/毫秒等多种时间单位。
  2. 真正的多规则独立:每条规则拥有独立的 count/interval/timeUnit,彻底解决多维度共享参数的瓶颈问题。
  3. 原子化保障:单 key Lua 脚本,将回收、检查、扣减三步合一,简洁高效。
  4. 无惧集群:通过 Hash Tag 巧妙兼容 Redis Cluster。
  5. 高性能:支持 Lua 脚本 SHA 预加载,减少网络传输开销。
  6. 生产就绪:内置降级支持、正确处理各种代理 IP 场景。

希望本文能帮助你深入理解分布式限流的实现原理,并将其灵活应用于你的实际项目中!

Logo

更多推荐