Redis + Lua 脚本实现计数器限流
限流
系统高并发的三大保障有:缓存、降级、限流
限流:限制系统或者某个服务在单位时间内处理请求的数量,防止系统超负荷,保障服务正常运行。常用算法有:计数器、令牌桶和漏桶等。
限流中的两个概念:
- 阈值:比如将QPS限制为500,那么说明每秒处理500个请求,通过设置阈值,控制系统负载,达到防止超负荷的目的。
拒绝策略:那么被阈值拒之门外的请求应该作何处理呢?拒绝策略提供了方案,常见的有:
- 直接拒绝:直接拒绝超过阈值的请求,不予处理。
- 排队等待:将超过阈值的请求放入队列,等候处理。
不同业务场景下选择合适的拒绝策略能够提高用户的体验。
目前比较主流的两个限流方案
- 网关层:限流在所有应用的入口处。
- 中间件限流:限流信息存储在中间件(如Redis)中,每个组件可以从此获得事实的限流信息,来决定操作。
//计数器限流示意
public class Limiter{
private final int limit = 10;
private final int timeWindow = 1000;
private long time;
private AtomicInteger reqCount = new AtomicInteger(0);
public Limiter(){
this.time = System.currentTimeMillis();
}
// 每次请求前使用
public boolean limit(){
long now = System.currentTimeMillis();
if(now < time + timeWindow){
reqCount.addAndGet(1);
return reqCount.get() < limit;
}else{
// 超出时间窗口,重新计数
time = now;
reqCount = new AtomicInteger(0);
return true;
}
}
}什么是Lua脚本?
一种轻量级、嵌入式脚本语言,常用游戏开发、脚本编程和嵌入式系统中。Redis从2.6开始支持Lua脚本,可以通过 EVAL 命令执行Lua脚本,实现原子操作,避免复杂的多步操作。
Lua脚本类似于MySQL的事务,存储一组指令,这些指令要么全部执行成功,要么全部失败,具有原子性,在开发中使用 Lua 脚本,可以将其看作是一段具有业务逻辑的代码块,因为我们需要用到原子性的一组操作,必然是业务需求。
Lua脚本在Redis中的好处:
- 原子性操作
- 减少网络开销:一次性组装多条命令,减少Redis通信开销
- 便利复杂操:作实现复杂操作,原本Redis命令执行可能很繁琐
常见使用场景:
- 分布式锁:Redisson 分布式锁底层就包括Lua脚本
- 计数器限流:Lua脚本实现精确计数器限流,避免并发问题
- 复杂事务:Lua脚本处理多步事务,保障操作完整性。
项目应用
在项目中应用Redis+Lua脚本实现计数器限流,避免频繁等重复或者恶意的提交,减少系统的异常压力。我们不仅实现限流,还应用自定义注解 + 切面,实现灵活的限流。
Lua 脚本配置
首先将脚本配置注入
public class RedisRateLimiterConfig {
@Bean
public DefaultRedisScript<Long> limitScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(limitScriptText());
redisScript.setResultType(Long.class);
return redisScript;
}
/**
* 限流Lua脚本(基于计数器算法)
*/
private String limitScriptText() {
return "local key = KEYS[1]\n" +
"local count = tonumber(ARGV[1])\n" +
"local time = tonumber(ARGV[2])\n" +
"local current = redis.call('get', key)\n" +
"if current and tonumber(current) > count then\n" +
" return tonumber(current)\n" +
"end\n" +
"current = redis.call('incr', key)\n" +
"if tonumber(current) == 1 then\n" +
" redis.call('expire', key, time)\n" +
"end\n" +
"return tonumber(current)";
}
}Lua 脚本流程:
- 读取key计数
- 如果key存在,且值超过限制,返回计数,代表超出阈值
- 计数+1,如果不存在,计数会被设置为1
- 如果计数==1,意味着是第一次访问,设置过期时间
- 返回计数
自定义注解
@Target(ElementType.METHOD) // 表示只能用于方法
@Retention(RetentionPolicy.RUNTIME) // 表示运行时存活,可被反射获取
@Documented
public @interface RateLimiter {
/**
* 限流key(前缀)
*/
String key() default "RATE_LIMIT:";
/**
* 限流时间窗口,单位秒
*/
int time() default 60;
/**
* 限流次数
*/
int count() default 100;
/**
* 限流类型(默认/按IP)
*/
LimitType limitType() default LimitType.DEFAULT;
}切面
private RedisTemplate<Object, Object> redisTemplate;
private RedisScript<Long> limitScript;
@Autowired
public void setRedisTemplate(RedisTemplate<Object, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Autowired
public void setLimitScript(RedisScript<Long> limitScript) {
this.limitScript = limitScript;
}
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) {
int time = rateLimiter.time();
int count = rateLimiter.count();
String key = buildKey(rateLimiter, point);
List<Object> keys = Collections.singletonList(key);
Long current = redisTemplate.execute(limitScript, keys, count, time);
if (current == null || current.intValue() > count) {
throw new ServiceException("访问过于频繁,请稍候再试");
}
log.info("🔒 限流:key={}, 当前请求次数={}, 限制次数={}", key, current, count);
}
private String buildKey(RateLimiter rateLimiter, JoinPoint point) {
StringBuilder sb = new StringBuilder(rateLimiter.key());
if (rateLimiter.limitType() == LimitType.IP) {
sb.append(IpUtils.getIpAddr(ServletUtils.getRequest())).append(":");
}
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
sb.append(method.getDeclaringClass().getName()).append(".").append(method.getName());
return sb.toString();
}定义切面,执行带有@RateLimiter注解的方法之前,会先执行 doBefore,doBefore就是我们定义的限流逻辑:
- 从注解ratelimiter类获取限流配置信息(底层是反射,这就是为什么注解周期要设置成运行时)
- 构造key,这里加入IP来构造,并且通过反射获取方法的所在类和方法名,结合构造,最终是RATE_LIMIT:IP:com.example.test.login 这种形式,以达到限制同一IP请求同一方法频率的目的。
- 执行Lua脚本限流逻辑,如果超出限制,抛出异常。
如何分析改进策略?
- 某接口限流次数很多(例如 /login 被拒绝上千次)
说明限额太低或流量高峰明显
提高 count 或缩短 time - 某些 IP 频繁被限流
说明有刷接口风险
对该 IP 单独设更低限额或拉黑 - 某时段(比如 9:00-9:30)限流集中
说明系统高峰期
调整限流窗口、引入“分时段动态限流” - 限流接口都是低价值接口(如 /ping)
说明资源浪费
可以放宽或取消限流 - 限流导致主业务接口大量被拒绝
说明策略太激进
调整全局限额或使用令牌桶平滑限流