实现
本教程以SpringBoot项目为例:
maven依耐导入
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.14</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.15.6</version>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.16</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
yml配置
server:
port: 8080
spring:
redis:
port: 6379
host: 192.168.101.128
password: 123321
user:
limit:
key: UserService
自定义限流注解
package com.axl.rate.config;
import org.redisson.api.RateIntervalUnit;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface GlobalRateLimiter {
String key();
long rate();
long rateInterval() default 1;
RateIntervalUnit rateIntervalUnit() default RateIntervalUnit.SECONDS;
}
使用Aop进行注解增强
package com.axl.rate.config;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.Redisson;
import org.redisson.api.RRateLimiter;
import org.redisson.api.RateIntervalUnit;
import org.redisson.api.RateType;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.Codec;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.http.codec.cbor.Jackson2CborEncoder;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
//令牌桶限流
@Aspect
@Component
@Slf4j
public class GlobalRateLimiterAspect {
@Resource
private Redisson redisson;
@Value("${user.limit.key}")
private String applicationName;
@Pointcut(value = "@annotation(com.axl.rate.config.GlobalRateLimiter)")
public void cut(){
}
@Around(value = "cut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
String className = method.getDeclaringClass().getName();
String methodName = method.getName();
GlobalRateLimiter globalRateLimiter = method.getDeclaredAnnotation(GlobalRateLimiter.class);
String key = globalRateLimiter.key();
long rate = globalRateLimiter.rate();
long rateInterval = globalRateLimiter.rateInterval();
RateIntervalUnit rateIntervalUnit = globalRateLimiter.rateIntervalUnit();
key = applicationName + "_" + className + "_" + methodName + "_" + key;
log.info("设置限流锁key={}", key);
RRateLimiter rateLimiter = redisson.getRateLimiter(key);
// 如果限流器不存在,就创建一个RRateLimiter限流器
if (!rateLimiter.isExists()) {
log.info("设置流量,rate={},rateInterval={},rateIntervalUnit={}", rate, rateInterval, rateIntervalUnit);
//rateLimiter.trySetRate就是设置限流参数,RateType有两种,OVERALL是全局限流 ,PER_CLIENT是单Client限流(可以认为就是单机限流)
//设置在rateInterval时间内允许访问rate次
rateLimiter.trySetRate(RateType.OVERALL, rate, rateInterval, rateIntervalUnit);
//设置一个过期时间,避免key一直存在浪费内存,这里设置为延长5分钟
long millis = rateIntervalUnit.toMillis(rateInterval);
this.redisson.getBucket(key).expire(Long.sum(5 * 1000 * 60, millis), TimeUnit.MILLISECONDS);
}
//调用rateLimiter的tryAcquire()或者acquire()方法即可获取许可
//申请一份许可,直到成功
// rateLimiter.acquire(1);
//申请一份许可,5秒内未申请到就放弃
boolean acquire = rateLimiter.tryAcquire(1,5,TimeUnit.SECONDS);
if (!acquire) {
//这里直接抛出了异常 也可以抛出自定义异常,通过全局异常处理器拦截进行一些其他逻辑的处理
throw new RuntimeException("请求频率过高,此操作已被限制");
}
return joinPoint.proceed();
}
}
测试使用
package com.axl.rate.controller;
import com.axl.rate.config.GlobalRateLimiter;
import com.axl.rate.pojo.Result;
import org.redisson.api.RateIntervalUnit;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/user")
public class UserController {
private static int count = 0;
@PostMapping("/login")
@GlobalRateLimiter(key="login",rate=3,rateInterval = 60,rateIntervalUnit = RateIntervalUnit.SECONDS)
public Result login(){
System.out.println(LocalDateTime.now() + "访问:" + ++count);
return new Result(200,"访问成功!");
}
}
RRateLimiter的实现
tryAcquire()
接下来我们顺着tryAcquire()方法来看下它的实现方式,在RedissonRateLimiter类中,我们可以看到最底层的实现是tryAcquireAsync()方法。
/**
* @Param permits 申请许可的份数
* @Param timeout 申请许可允许等待的时间
*/
public boolean tryAcquire(long permits, long timeout, TimeUnit unit) {
return (Boolean)this.get(this.tryAcquireAsync(permits, timeout, unit));
}
tryAcquireAsync()
private <T> RFuture<T> tryAcquireAsync(RedisCommand<T> command, Long value) {
return this.commandExecutor
.evalWriteAsync(this.getRawName(),
LongCodec.INSTANCE, command,
“=======================”
一大段Lua代码
“=======================”
, Arrays.asList(this.getRawName(),
this.getValueName(),
this.getClientValueName(),
this.getPermitsName(),
this.getClientPermitsName()),
new Object[]{value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong()});
}
最重要的就是下面这段Lua代码
local rate = redis.call( "hget", KEYS[1],"rate" ) # 3
local interval = redis.call("hget", KEYS[1], "interval") # 60
local type = redis.call( "hget", KEYS[1],"type") # 0
assert(rate ~= false and interval ~= false and type ~= false,"RateLimiter is not initialized"
local valueName = KEYS[2] # {key}:value 用来存储剩余许可数量
local permitsName = KEYs[4] # {key} : permits 记录了所有许可发出的时间戳
# 如果是单实例模式,name信息后面就需要拼接上clientId来区分出来了
if type == "1" then
valueName = KEYS[3] #{key} : value:b474c7d5-862c-4be2-9656-f401
permitsName = KEYS[5] #{key} :permits : b474c7d5-862c-4be2-9656-f4
end
# 对参数校验
assert(tonumber(rate) >= tonumber(ARGV[1]),"Requested permits amount could not exceed defined rate")
# 获取当前还有多少许可
local currentValue = redis.call( "get", valueName)
local res
#如果有记录当前还剩余多少许可
if currentValue ~= false then
#回收已过期的许可数量
local expiredValues = redis.call("zrangebyscore", permitsName,0,tonumber(ARGV[2]) - interval)
local released = 0
for i, v in ipairs(expiredValues) do
local random,permits = struct.unpack("BcOI", v)
released = released + permits
end
#清理已过期的许可记录
if released > then
redis.call("zremrangebyscore", permitsName,0, tonumber(ARGV[2]) - interval)
if tonumber(currentValue) + released > tonumber(rate) then
currentValue = tonumber(rate) - redis.call( "zcard", permitsName)
else
currentValue = tonumber(currentValue) + releasedend
end
redis.call( "set", valueName, currentValue)
end
#清理已过期的许可记录
if released > then
redis.call("zremrangebyscore", permitsName,0, tonumber(ARGV[2]) - interval)
if tonumber(currentValue) + released > tonumber(rate) then
currentValue = tonumber(rate) - redis.call( "zcard", permitsName)
else
currentValue = tonumber(currentValue) + releasedend
end
redis.call( "set", valueName,currentValue)
end
#ARGVpermit timestamprandom, random是一个随机的8字节
#如果剩余许可不够,需要在res中返回下个许可需要等待多长时间
if tonumber( currentValue) < tonumber(ARGV[1]) then
local firstvalue = redis.call( "zrange", permitsName,0,0,"withscores")
res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]))
else
redis.call("zadd", permitsName,ARGV[2],struct.pack( "BcOT",string. len(ARGV[3]),ARGV[3],ARGV[1]))
#减小可用许可量
redis.cal1( "decrby", valueName,ARGV[1])
res = nil
end
else #反之,记录到还有多少许可,说明是初次使用或者之前已记录的信息已经过期了,就将配置rate
redis.call( "set", valueName,rate)
redis.call("zadd" , permitsName,ARGV[2],struct.pack( "BcOr", string.len(ARGV[3]),Ls, ARGV[1]))
redis.call( "decrby", valueName,ARGV[1j)
res = nil
end
local tt1 = redis.cal1( "pttl",KEYS[1])
#重置
if ttl > 0 then
redis.call( "pexpire", valueName, tt1)
redis.call( "pexpire", permitsName, ttl)
end
return res
上述lua的执行流程为:
可以看到Redis用了多个字段来存储限流的信息,也有各种各样的操作,那Redis是如何保证在分布式下这些限流信息数据的一致性的?
答案是不需要保证,在这个场景下,信息天然就是一致性的。原因是Redis的单进程数据处理模型,在同一个Key下,所有的eval请求都是串行的,所以不需要考虑数据并发操作的问题。在这里,Redisson也使用了HashTag,保证所有的限流信息都存储在同一个Redis实例上。
RRateLimiter使用时注意事项
RRateLimiter是非公平限流器
具体表现就是如果多个实例(机器)取竞争这些许可,很可能某些实例会获取到大部分,而另外一些实例可怜巴巴仅获取到少量的许可,也就是说容易出现旱的旱死 涝的涝死的情况。在使用过程中,你就必须考虑你能否接受这种情况,如果不能接受就得考虑用某些方式尽可能让其变公平。
限流的上限取决于Redis单实例的性能
RRateLimiter在Redis上所存储的信息都必须在一个Redis实例上,所以它的限流QPS的上限就是Redis单实例的上限,比如你Redis实例就是1w QPS,你想用RRateLimiter实现一个2w QPS的限流器,必然实现不了。 那有没有突破Redis单实例性能上限的方式?单限流器肯定是实现不了的,我们可以拆分多个限流器,比如我搞10个限流器,名词用不一样的,然后每台机器随机使用一个限流器限流,实际的流量不就被分散到不同的限流器上了吗,总的限流上线不也就上来了。