关于通过RateLimiter(或redis Lua) 和Spring Aop 实现限流

3,835 阅读4分钟

限流

当遇到某个请求量激增时,可能会倒是接口占用过多的服务器资源,使得其他请求响应时间过慢或超时,有可能导致服务器挂机。这时可能通过对请求进行限制,对于部分超时请求,快速返回失败;

限流的算法

计数器算法

在规定的时间内限制能通过的请求数,如果限制1秒内只能通过100个请求,那在1秒内,每个请求都使数量加1,累计达到100次后,后续请求都被拒绝;一秒后,计数器重置为零,重新计数

漏桶算法

水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率

令牌桶算法

令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。从原理上看,令牌桶算法和漏桶算法是相反的,一个“进水”,一个是“漏水”。

RateLimiter简介

Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法(Token Bucket)来完成限流,RateLimiter经常用于限制对一些物理资源或者逻辑资源的访问速率.它支持两种获取permits接口,一种是如果拿不到立刻返回false,一种会阻塞等待一段时间看能不能拿到.

创建一个RateLimiter ,设置速率为每秒生成 5 个令牌, 那么桶的最大值也为 5

RateLimiter rateLimiter = RateLimiter.create(5)

RateLimiter 提供了一些方法用于帮助判断

方法 作用
setRate(double permitsPerSecond) 设置每秒向桶中放入令牌的数量
accquire() 获取一个令牌,阻塞直到该请求可以为止,返回花费的时间
accquire(int permits) 获取指定数量的令牌, 该方法也会阻塞, 返回值为获取到这 N 个令牌花费的时间
tryAcquire() 判断时候能获取到令牌, 如果不能获取立即返回 false
tryAcquire(int permits) 判断时候能获取到n个令牌, 如果不能获取立即返回 false
tryAcquire(long timeout,TimeUnit unit) 能否在指定时间内获取令牌,不能则返回false
tryAcquire(int permits,long timeout,TimeUnit unit) 能否在指定时间和单位内获取令牌,不能则返回false

RateLimit实例

在pom中添加

       <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>28.0-jre</version>
        </dependency>

定义一个注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {


    /**
     * 每秒向桶放入令牌的数量
     */
     double perSecond() default Double.MAX_VALUE;

    /**
     * 获取令牌的等待时间
     *
     * @return
     */
    int timeOut() default 0;

    /**
     * 超时时间单位
     *
     * @return
     */
    TimeUnit timeOutUnit() default  TimeUnit.MILLISECONDS;

}

通过Aop判断是否使用注解@RateLimit和限流

@Log4j2
@Aspect
@Configuration
public class RateLimitAspect {

    private RateLimiter rateLimiter = RateLimiter.create(Double.MAX_VALUE);

    @Around("execution(public * *(..)) && @annotation(com.yuan.redis.authorization.RateLimit)")
    public Object interceptor(ProceedingJoinPoint pjp) throws Throwable {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        if (method.isAnnotationPresent(RateLimit.class)) {
            RateLimit rl = method.getAnnotation(RateLimit.class);
            rateLimiter.setRate(rl.perSecond());
            if (!rateLimiter.tryAcquire(rl.timeOut(), rl.timeOutUnit())) {
                throw new ApiException("访问人数太多,请稍后再试试", ApiConstants.ERROR100600);
            }
        }
        return pjp.proceed();
    }
}

在接口上加注解@RateLimit

    @ApiOperation(value = "测试限流", notes = "测试限流")
    @RequestMapping(value = "/testRateLimiter.json", method = RequestMethod.POST)
    @ApiResponses({@ApiResponse(code = 5000001, message = "参数错误")})
    @RateLimit(perSecond =100, timeOut = 100)
    public Result<Test>  testRateLimiter() {
        return Result.jsonStringOk();
    }

通过jmeter 启用500线程压测,发现有部分不成功,加上@RateLimit(perSecond =100, timeOut = 100)后吞吐量降低了。

Redis Lua 实现限流

RateLimit是单机版的实现,接下来使用Redis Lua 实现简单的分布式限流(计数器算法实现),Redis Lua 代码如下

    /**
     * 通过KEYS[1]拿到value(不存在则赋值0),该值+1判断是否大于请求的最大次数,大于则加一并返回-1(表明已经被限流),小于则加一并设置过期为传入KEYS[3]
     */
    public static String limit = "local key=KEYS[1]\n " +
            "local limit=tonumber(KEYS[2])\n" +
            "local num = tonumber(redis.call('get', key) or \"0\")\n" +
            "if num+1 > limit then \n" +
            "    redis.call(\"INCRBY\", key,\"1\") \n" +
            "    return -1 \n" +
            "else  \n" +
            "    redis.call(\"INCRBY\", key,\"1\")\n" +
            "        redis.call(\"expire\", key,KEYS[3]) \n" +
            "    return num \n" +
            "end";
            

java 代码,如果我们设置limitNum为100,expireTime为1,则表示单位时间1秒内只能有100个访问通过

public class LimitServiceImpl implements LimitService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Long limit(String key, int limitNum, int expireTime) {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setResultType(Long.class);
        redisScript.setScriptText(LuaScript.limit);
        List<String> keyList = new ArrayList();
        keyList.add(key);
        keyList.add(limitNum + "");
        keyList.add(expireTime + "");
        Long result = stringRedisTemplate.execute(redisScript, keyList);
        return result;
    }
}

在代码上实现限流也可以通过注解形式实现,和RateLimiter 和Spring Aop实现限流差不多。

在设置limit为50,expireTime为1,在jmeter下进行1秒内100个线程的访问,请求的前49个和第51个成功返回成功,其他返回错误。

如果需要用户对某个接口的访问次数,可以把key设置为该用户的唯一标识。