封装 Redis + Lua 实现多维度分布式限流
本文围绕高并发场景下的分布式限流展开,介绍了基于 Redisson、自定义 Lua 脚本、AOP 及自定义注解的限流解决方案。该方案通过 @RateLimiter 注解实现声明式使用,AOP 切面拦截目标方法,核心依赖 Lua 脚本原子性执行滑动窗口算法,完成过期令牌回收、配额检查与扣减,确保限流精准。方案支持多维度限流、兼容 Redis Cluster,可灵活配置时间窗口和请求限额,对比 Red
目录
一、前言
在高并发场景下,服务限流是保障系统稳定性和可用性的关键手段。常见的限流算法有计数器、固定窗口、滑动窗口、令牌桶及漏桶算法等。
本文将重点介绍如何利用 Redisson + 自定义 Lua 脚本,结合 AOP(面向切面编程)和 自定义注解,来构建一个灵活、易用且支持分布式环境的限流解决方案。该方案支持多维度限流,并完美兼容 Redis Cluster 模式。
二、整体架构设计
本项目结合 AOP 和自定义注解来实现基于 Redisson 的分布式限流,是业界广泛采用且推荐的一种模式。其优势在于:
- 关注点分离 (AOP): 限流逻辑本质上与核心业务逻辑解耦,属于典型的横切关注点。AOP 允许我们将限流的通用逻辑从业务方法中抽离出来,集中到一个切面类中管理。
- 声明式使用 (Annotation): 通过定义
@RateLimit注解,开发者只需在需要限流的方法上添加注解并配置参数即可,极大降低了侵入性。 - 分布式支持: 利用 Redis 作为中心存储,通过 Lua 脚本保证操作的原子性。
- 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 前缀、时间窗口大小、窗口内允许的请求数、限流维度等参数。
参数说明:
window和limit这两个参数直接决定了系统的 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 -- 通过
脚本逻辑图解:
- 初始化: 如果
value_key不存在,则初始化为最大令牌数。 - 回收: 从有序集合
permits_key中找出所有已经超出时间窗口的过期令牌记录,累加它们的数量,将其加回到value_key中,并删除这些过期记录。 - 判断: 检查
value_key中的当前可用令牌数是否满足本次请求的permits。 - 通过/拒绝: 如果满足,则记录本次申请并扣减令牌,返回 1;否则返回 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 客户端,提供脚本执行能力 |
技术亮点回顾:
- 灵活的时间窗口:支持秒/分/时/天/毫秒等多种时间单位。
- 真正的多规则独立:每条规则拥有独立的
count/interval/timeUnit,彻底解决多维度共享参数的瓶颈问题。 - 原子化保障:单 key Lua 脚本,将回收、检查、扣减三步合一,简洁高效。
- 无惧集群:通过 Hash Tag 巧妙兼容 Redis Cluster。
- 高性能:支持 Lua 脚本 SHA 预加载,减少网络传输开销。
- 生产就绪:内置降级支持、正确处理各种代理 IP 场景。
希望本文能帮助你深入理解分布式限流的实现原理,并将其灵活应用于你的实际项目中!
更多推荐

所有评论(0)