分布式限流的关键就是需要将限流服务做成全局的,统一的。可以采用Redis+Lua技术实现,通过这种技术可以实现高并发和高性能的限流。
Lua是一种轻量小巧的脚本编程语言,用标准的C语言编写的开源脚本,其设计的目的是为了嵌入到应用程序中,为应用程序提供灵活的扩展和定制功能。
Redis+Lua脚本实现分布式限流思路
我们可以使用Redia+Lua脚本的方式来对我们的分布式系统进行统一的全局限流,Redis+Lua实现的Lua脚本:
-- 下标从 1 开始
local key = KEYS[1]
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local expired = tonumber(ARGV[3])
-- 最大访问量
local max = tonumber(ARGV[4])
-- 清除过期的数据
-- 移除指定分数区间内的所有元素,expired 即已经过期的 score
-- 根据当前时间毫秒数 - 超时毫秒数,得到过期时间 expired
redis.call('zremrangebyscore', key, 0, expired)
-- 获取 zset 中的当前元素个数
local current = tonumber(redis.call('zcard', key))
local next = current + 1
if next > max then
-- 达到限流大小 返回 0
return 0;
else
-- 往 zset 中添加一个值、得分均为当前时间戳的元素,[value,score]
redis.call("zadd", key, now, now)
-- 每次访问均重新设置 zset 的过期时间,单位毫秒
redis.call("pexpire", key, ttl)
return next
end
(1)在Lua脚本中,有两个全局变量,用来接收Redis应用端传递的键和其他参数,分别为:KEYS、ARGV;
(2)在应用端传递KEYS时是一个数组列表,在Lua脚本中通过索引下标方式获取数组内的值。
(3)在应用端传递ARGV时参数比较灵活,可以是一个或多个独立的参数,但对应到Lua脚本中统一用ARGV这个数组接收,获取方式也是通过数组下标获取。
(4)以上操作是在一个Lua脚本中,又因为我当前使用的是Redis 5.0版本(Redis 6.0支持多线程),执行的请求是单线程的,因此,Redis+Lua的处理方式是线程安全的,并且具有原子性。
这里,需要注意一个知识点,那就是原子性操作:如果一个操作时不可分割的,是多线程安全的,我们就称为原子性操作。
接下来,我们可以使用如下Java代码来判断是否需要限流。
Redis+Lua脚本实现分布式限流案例
创建注解
// FileName: RateLimiter.java
package com.xkcoding.ratelimit.redis.annotation;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
long DEFAULT_REQUEST = 10;
/**
* max 最大请求数
*/
@AliasFor("max") long value() default DEFAULT_REQUEST;
/**
* max 最大请求数
*/
@AliasFor("value") long max() default DEFAULT_REQUEST;
/**
* 限流key
*/
String key() default "";
/**
* 超时时长,默认1分钟
*/
long timeout() default 1;
/**
* 超时时间单位,默认 分钟
*/
TimeUnit timeUnit() default TimeUnit.MINUTES;
}
在RateLimiter注解内部,我们为value属性添加了别名limit,在我们真正使用@RateLimiter注解时,即可以使用@RateLimiter(10),也可以使用@RateLimiter(value=10),还可以使用
创建切面类 创建注解后,我们就来创建一个切面类RateLimiterAspect,RateLimiterAspect类的作用主要是解析@RateLimiter注解,并且执行限流的规则。这样,就不需要我们在每个需要限流的方法中执行具体的限流逻辑了,只需要我们在需要限流的方法上添加@RateLimiter注解即可,具体代码如下所示。
package com.xkcoding.ratelimit.redis.aspect;
import cn.hutool.core.util.StrUtil;
import com.xkcoding.ratelimit.redis.annotation.RateLimiter;
import com.xkcoding.ratelimit.redis.util.IpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class RateLimiterAspect {
private final static String SEPARATOR = ":";
private final static String REDIS_LIMIT_KEY_PREFIX = "limit:";
private final StringRedisTemplate stringRedisTemplate;
private final RedisScript<Long> limitRedisScript;
@Pointcut("@annotation(com.xkcoding.ratelimit.redis.annotation.RateLimiter)")
public void rateLimit(){
}
@Around("rateLimit()")
public Object pointCut(ProceedingJoinPoint point) throws Throwable {
MethodSignature methodSignature= (MethodSignature) point.getSignature();
Method method=methodSignature.getMethod();
RateLimiter rateLimiter= AnnotationUtils.findAnnotation(method,RateLimiter.class);
if (rateLimiter!=null){
String key=rateLimiter.key();
if (StringUtils.isEmpty(key)){
key = method.getDeclaringClass().getName()+ StrUtil.DOT+method.getName();
}
key=key+SEPARATOR+ IpUtil.getIpAddr();
long max = rateLimiter.max();
long timeout = rateLimiter.timeout();
TimeUnit timeUnit = rateLimiter.timeUnit();
boolean limited=shouldLimited(key,timeout,timeUnit,max);
if (limited){
throw new RuntimeException("手速太快了,慢点儿吧~");
}
}
return point.proceed();
}
private boolean shouldLimited(String key,Long timeout,TimeUnit timeUnit,Long max){
// 最终的 key 格式为:
// limit:自定义key:IP
// limit:类名.方法名:IP
key = REDIS_LIMIT_KEY_PREFIX + key;
// 统一使用单位毫秒
long ttl = timeUnit.toMillis(timeout);
// 当前时间毫秒数
long now = Instant.now().toEpochMilli();
long expired = now - ttl;
Long executeTimes=stringRedisTemplate.execute(limitRedisScript, Collections.singletonList(key),ttl+"",now+"",expired+"",max+"");
if(executeTimes!=null){
if(executeTimes==0){
log.error("【{}】在单位时间 {} 毫秒内已达到访问上限,当前接口上限 {}", key, ttl, max);
return true;
}else {
log.error("【{}】在单位时间 {} 毫秒内已达到访问上限,当前接口上限 {}", key, ttl, max);
return false;
}
}
return false;
}
}
上述代码会读取项目classpath目录下的limit.lua脚本文件来确定是否执行限流的操作,调用limit.lua文件执行的结果返回0则表示执行限流逻辑,否则不执行限流逻辑。既然,项目中需要使用Lua脚本,那么,接下来,我们就需要在项目中创建Lua脚本。
创建limit.lua脚本文件
在项目的classpath目录下创建limit.lua脚本文件,文件的内容如下所示。
-- 下标从 1 开始
local key = KEYS[1]
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local expired = tonumber(ARGV[3])
-- 最大访问量
local max = tonumber(ARGV[4])
-- 清除过期的数据
-- 移除指定分数区间内的所有元素,expired 即已经过期的 score
-- 根据当前时间毫秒数 - 超时毫秒数,得到过期时间 expired
redis.call('zremrangebyscore', key, 0, expired)
-- 获取 zset 中的当前元素个数
local current = tonumber(redis.call('zcard', key))
local next = current + 1
if next > max then
-- 达到限流大小 返回 0
return 0;
else
-- 往 zset 中添加一个值、得分均为当前时间戳的元素,[value,score]
redis.call("zadd", key, now, now)
-- 每次访问均重新设置 zset 的过期时间,单位毫秒
redis.call("pexpire", key, ttl)
return next
end
接口添加注解
注解类、解析注解的切面类、Lua脚本文件都已经准备好。那么,接下来,我们在TestController类中在sendMessage2()方法上添加@RateLimiter注解,并且将limit属性设置为10,如下所示。
@RateLimiter(limit = 10)
@RequestMapping("/boot/send/message2")
public String sendMessage2(){
//记录返回接口
String result = "";
boolean flag = messageService.sendMessage("恭喜您成长值+1");
if (flag){
result = "短信发送成功!";
return result;
}
result = "哎呀,服务器开小差了,请再试一下吧";
return result;
}
好了,今天就聊到这儿吧!别忘了点个赞,给个在看和转发,让更多的人看到,一起学习,一起进步!!