Redisson实现分布式限流实现及原理

182 阅读5分钟

实现

本教程以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,0tonumber(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,0tonumber(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,00"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的执行流程为: image.png

可以看到Redis用了多个字段来存储限流的信息,也有各种各样的操作,那Redis是如何保证在分布式下这些限流信息数据的一致性的?

答案是不需要保证,在这个场景下,信息天然就是一致性的。原因是Redis的单进程数据处理模型,在同一个Key下,所有的eval请求都是串行的,所以不需要考虑数据并发操作的问题。在这里,Redisson也使用了HashTag,保证所有的限流信息都存储在同一个Redis实例上。

RRateLimiter使用时注意事项

RRateLimiter是非公平限流器

具体表现就是如果多个实例(机器)取竞争这些许可,很可能某些实例会获取到大部分,而另外一些实例可怜巴巴仅获取到少量的许可,也就是说容易出现旱的旱死 涝的涝死的情况。在使用过程中,你就必须考虑你能否接受这种情况,如果不能接受就得考虑用某些方式尽可能让其变公平。

限流的上限取决于Redis单实例的性能

RRateLimiter在Redis上所存储的信息都必须在一个Redis实例上,所以它的限流QPS的上限就是Redis单实例的上限,比如你Redis实例就是1w QPS,你想用RRateLimiter实现一个2w QPS的限流器,必然实现不了。 那有没有突破Redis单实例性能上限的方式?单限流器肯定是实现不了的,我们可以拆分多个限流器,比如我搞10个限流器,名词用不一样的,然后每台机器随机使用一个限流器限流,实际的流量不就被分散到不同的限流器上了吗,总的限流上线不也就上来了。