限流令牌算法

在大型分布式系统中,限流是一种重要的防护机制,可以帮助我们控制流量并减轻系统的负担。Google的Guava库提供了一种方便的限流器实现,可以帮助我们轻松地实现限流功能。本文将介绍Guava中限流器的基本概念和使用方法。
一、什么是限流器?
限流器是一种控制系统流量的机制,可以帮助我们在高负载情况下保护系统免受过载和崩溃的风险。通过限制进入系统的请求速率,我们可以避免服务器过载和资源耗尽的问题。
常用的限流方式和场景有:

限制总并发数(比如数据库连接池、线程池)
限制瞬时并发数(如nginx的limitconn模块,用来限制瞬时并发连接数,Java的Semaphore也可以实现)
限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limitreq模块,限制每秒的平均速率)
其他:比如如限制远程接口调用速率、限制MQ的消费速率。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
二、Guava中限流器的实现原理
Guava中的限流器实现基于令牌桶算法,它是一种流量控制算法,可以帮助我们平滑地限制请求速率。限流器将请求速率限制为指定的速率,以确保系统能够在高负载情况下保持稳定。
Guava中的限流器使用一个桶来保存令牌,桶中的令牌代表可用的请求数量。每当一个请求到达时,限流器会尝试从桶中取出一个令牌,如果桶中没有可用的令牌,则该请求将被阻塞,直到有足够的令牌可用为止。当令牌被用尽时,限流器将根据配置的速率自动添加新的令牌到桶中,以确保系统能够继续处理请求。
guava单机限流RateLimiter简介
RateLimiter是基于令牌桶算法实现的一个限流组件,其代码看起来很简单,一共就两个类:抽象父类 RateLimiter和实际的 SmoothRateLimiter。其中 SmoothWarmingUP和 SmmothBursty是 SmoothRateLimiter的两个内部类。但实际真的要看懂也需要花点时间的,这里其实主要就是算法上的考虑不好看懂。

RateLimiter提供了两种限流模式:
普通的限流SmoothBursty
带预热的限流。即在指定预热期,允许放过的流量逐渐增加。预热期结束后,允许放过的流量就等于设定的限流值。这个目的是为了解决软件重启等情况,由于缓存等还没有初始化化、jvm还是解释执行等,能够承受的流量比稳定运行后更小,防止在服务刚刚启动就被大流量打挂了,所以RateLimiter提供了一个预热器。
使用方式:

当然也提供了非阻塞的tryAcquire()方法。

RateLimiter属性简介
stableIntervalMicros:
令牌产生的稳定速率,只是这里的速率是转换成了两个令牌生产之间的时间间隔(毫秒)。之所以是稳定速率,是因为SmoothWarmUp,在预热阶段产生令牌的速率会低于这个值。RateLimiter初始化的时候,传入的permitsPerSecond表示的是每秒产生的产生的令牌个数,也就是说令牌的生产速率的时间单位就给固定了,那么stableIntervalMicros = 1s/permitsPerSecond。
Ps:速率(单位时间生产个数) = 时间段内总个数/时间长度 = 时间内长度/生产两个令牌的时间间隔 可以来表示生效速率。反过来使用两个令牌生产间隔其实也就可以表示速率。
maxPermits:
令牌桶的容量,即令牌桶中最大的令牌数。对于无预热的限流器, maxPermits = 1s/stableIntervalMicros。之所以要这么计算,是因为初始化RateLimiter时,传入的permitsPerSecond是个double,用小数来表达限流窗口不是1s的情况。
而对于有预热的限流器,预热期间,就是1s/stableIntervalMicros的一半。预热结束就是1s/stableIntervalMicros。
所以这个maxPermits的最大值,其实就是初始化RateLimiter的时候设置的限流阈值做了整数转换。
storedPermits:
令牌桶中当前拥有令牌的个数。
nextFreeTicketMicros:
下次无需等待就能直接获取token的时间。它的值的计算包含两部分:

按照正常速率生产令牌,下一次能够直接获得令牌的。比如令牌的生产速率是每秒100个,如果在T1时刻将令牌桶token清空了,那么下次无需等待就能获得令牌的时间就是T1之后的100ms处。

预支的令牌生产的时间。RateLimiter为了支持一定的流量突发,当一次调用acquire()的时候,如果当前令牌桶中没有足够的令牌,也不会阻塞当前acquire()请求,而是直接返回,然后将预支的那些令牌的生产时间累加到nextFreeTicketMicros,然后下次调用acquire()的时候就会阻塞更久。
比如:当前令牌桶中的令牌数storedPermits=2,但是acquire(5)的时候不会立马阻塞,而是将超支的3个令牌的生产时间转义到下次调用acquire()的时候。
即nextFreeTicketMicros += 3*100ms。
RateLimiter的设计哲学: 它允许瞬间的流量波峰超过QPS,但瞬间过后的请求将会等待较长的时间来缓解上次的波峰,以使得平均的QPS等于预定值。
这4个参数是SmoothBursty和SmoothWarmUp共有的,且维护逻辑也都是一样的。

SmoothBursty自己的属性

maxBurstSeconds:这个是影响maxPermits,在计算maxPermits的时候,实际是maxBurstSeconds * permitsPerSecond,如果maxBurstSeconds大于1,那其实就允许令牌桶中多余初始化RateLimiter时指定的阈值,以应对一定的突发流量。在guava 30.1版本中,这个值还是写死的1.0.
SmoothWarmUp自己的属性:

warmupPeriodMicros:
预热期时间长度,这个是初始化RateLimiter传入的。
thresholdPermits:预热期内令牌桶内最大的令牌数。
其值=0.5 * warmupPeriodMicros/stableIntervalMicros
coldFactor:
预热期令牌生产速率的减缓因子。正常情况下,令牌的生产间隔就是stableIntervalMicros = 1s/permitsPerSecond,而在预热期,令牌的生产速率=coldFactor * stableIntervalMicros。
在guava 30.1版本中,这个值还是写死的3.0。
所以,对于限流阈值设置成1s内100个,那么stableIntervalMicros=100ms,但是在预热期令牌生产速率=3*100ms=300ms.
slope:
其值=(stableIntervalMicros * coldFactor - stableIntervalMicros) / (maxPermits - thresholdPermits)
三、如何使用Guava中的限流器?
在Guava中使用限流器非常简单,只需要按照以下步骤即可:

1.创建一个限流器对象
Guava中的限流器对象是通过RateLimiter类实现的。您可以使用RateLimiter.create(double permitsPerSecond)方法创建一个限流器对象,其中参数permitsPerSecond是每秒允许的请求数。

例如,以下代码创建一个每秒允许10个请求的限流器对象:

RateLimiter limiter =RateLimiter.create(10);
2.获取许可证
当您的应用程序需要处理一个请求时,可以使用RateLimiter.acquire()方法获取许可证。这个方法将会阻塞,直到有足够的令牌可用。如果限流器没有可用的令牌,则该方法将阻塞,直到有足够的令牌可用。

例如,以下代码获取一个许可证并处理请求:

limiter.acquire();
handleRequest();
3.尝试获取许可证
如果您希望尝试获取许可证而不是阻塞,则可以使用RateLimiter.tryAcquire()方法。这个方法将尝试获取许可证,如果限流器没有可用的令牌,则该方法将返回false,否则将返回true。

例如,以下代码尝试获取一个许可证:

if (limiter.tryAcquire()) {
handleRequest();
} else {
handleRateLimitedRequest();
}
4.调整速率
您还可以使用RateLimiter.setRate(double permitsPerSecond)方法动态地调整限流器的速率。这个方法可以帮助您根据系统负载和流量需求动态调整请求速率。

例如,以下代码动态调整限流器的速率:

limiter.setRate(20);
5.处理多个请求
如果您需要处理多个请求,则可以使用RateLimiter.acquire(int permits)方法获取多个许可证。这个方法将会阻塞,直到有足够的令牌可用。

例如,以下代码获取5个许可证并处理5个请求:

limiter.acquire(5);
handleRequest1();
handleRequest2();
handleRequest3();
handleRequest4();
handleRequest5();
四、Guava限流器的注意事项
在使用Guava限流器时,需要注意以下几点:

限流器的速率应该根据系统负载和流量需求进行动态调整。
如果您需要处理多个请求,请确保您的限流器对象能够处理足够的请求数量。
在高负载情况下,限流器可能会导致请求超时或错误,请确保您的应用程序能够处理这些情况。
限流器的速率应该设置为适当的值,以确保系统能够处理所有的请求。
五、总结
Guava中的限流器提供了一种简单而有效的方法来控制系统流量,以保护系统免受过载和崩溃的风险。使用Guava限流器,您可以轻松地实现限流功能,并根据系统负载和流量需求动态调整请求速率。在实现分布式系统时,使用限流器是一种重要的防护机制,可以帮助我们确保系统的稳定性和可靠性。
六、SpringBoot使用限流器示例
1、pom文件添加依赖

com.google.guava
guava
30.1-jre

2、给接口加上限流逻辑
@Slf4j
@RestController
@RequestMapping(“/limit”)
public class LimitController {
/**
* 限流策略 :1秒钟2个请求
*/
private final RateLimiter limiter = RateLimiter.create(2.0);

private DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

@GetMapping("/test1")
public String testLimiter() {
    // 500毫秒内,没拿到令牌,就直接进入服务降级
    boolean tryAcquire = limiter.tryAcquire(500, TimeUnit.MILLISECONDS);

    if (!tryAcquire) {
        log.warn("进入服务降级,时间{}", LocalDateTime.now().format(dtf));
        return "当前排队人数较多,请稍后再试!";
    }

    log.info("获取令牌成功,时间{}", LocalDateTime.now().format(dtf));
    return "请求成功";
}

}

以上用到了RateLimiter的2个核心方法:create()、tryAcquire(),以下为详细说明

acquire() 获取一个令牌, 改方法会阻塞直到获取到这一个令牌, 返回值为获取到这个令牌花费的时间
acquire(int permits) 获取指定数量的令牌, 该方法也会阻塞, 返回值为获取到这 N 个令牌花费的时间
tryAcquire() 判断时候能获取到令牌, 如果不能获取立即返回 false
tryAcquire(int permits) 获取指定数量的令牌, 如果不能获取立即返回 false
tryAcquire(long timeout, TimeUnit unit) 判断能否在指定时间内获取到令牌, 如果不能获取立即返回 false
tryAcquire(int permits, long timeout, TimeUnit unit) 同上
3、体验效果
通过访问测试地址:http://127.0.0.1:8080/limit/test1,反复刷新并观察后端日志

WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:37
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:37

WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:38
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:38
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:38
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:38
从以上日志可以看出,1秒钟内只有2次成功,其他都失败降级了,说明已经成功给接口加上了限流功能。

七、SpringBoot基于AOP实现接口限流
1、加入AOP依赖

org.springframework.boot
spring-boot-starter-aop

2、自定义限流注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Limit {
/**
* 资源的key,唯一
* 作用:不同的接口,不同的流量控制
*/
String key() default “”;

/**
 * 最多的访问限制次数
 */
double permitsPerSecond () ;

/**
 * 获取令牌最大等待时间
 */
long timeout();

/**
 * 获取令牌最大等待时间,单位(例:分钟/秒/毫秒) 默认:毫秒
 */
TimeUnit timeunit() default TimeUnit.MILLISECONDS;

/**
 * 得不到令牌的提示语
 */
String msg() default "系统繁忙,请稍后再试.";

}

3、使用AOP切面拦截限流注解
@Slf4j
@Aspect
@Component
public class LimitAop {
/**
* 不同的接口,不同的流量控制
* map的key为 Limiter.key
*/
private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();

@Around("@annotation(com.jianzh5.blog.limit.Limit)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    //拿limit的注解
    Limit limit = method.getAnnotation(Limit.class);
    if (limit != null) {
        //key作用:不同的接口,不同的流量控制
        String key=limit.key();
        RateLimiter rateLimiter = null;
        //验证缓存是否有命中key
        if (!limitMap.containsKey(key)) {
            // 创建令牌桶
            rateLimiter = RateLimiter.create(limit.permitsPerSecond());
            limitMap.put(key, rateLimiter);
            log.info("新建了令牌桶={},容量={}",key,limit.permitsPerSecond());
        }
        rateLimiter = limitMap.get(key);
        // 拿令牌
        boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());
        // 拿不到命令,直接返回异常提示
        if (!acquire) {
            log.debug("令牌桶={},获取令牌失败",key);
            this.responseFail(limit.msg());
            return null;
        }
    }
    return joinPoint.proceed();
}

/**
 * 直接向前端抛出异常
 * @param msg 提示信息
 */
private void responseFail(String msg)  {
    HttpServletResponse response=((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
    ResultData<Object> resultData = ResultData.fail(ReturnCode.LIMIT_ERROR.getCode(), msg);
    WebUtils.writeJson(response,resultData);
}

}

4、给需要限流的接口加上注解
@Slf4j
@RestController
@RequestMapping(“/limit”)
public class LimitController {

@GetMapping("/test2")
@Limit(key = "limit2", permitsPerSecond = 1, timeout = 500, timeunit = TimeUnit.MILLISECONDS,msg = "当前排队人数较多,请稍后再试!")
public String limit2() {
    log.info("令牌桶limit2获取令牌成功");
    return "ok";
}


@GetMapping("/test3")
@Limit(key = "limit3", permitsPerSecond = 2, timeout = 500, timeunit = TimeUnit.MILLISECONDS,msg = "系统繁忙,请稍后再试!")
public String limit3() {
    log.info("令牌桶limit3获取令牌成功");
    return "ok";
}

}

5、体验效果
通过访问测试地址:http://127.0.0.1:8080/limit/test2,反复刷新并观察输出结果:

正常响应时:

{“status”:100,“message”:“操作成功”,“data”:“ok”,“timestamp”:1632579377104}
触发限流时:

{“status”:2001,“message”:“系统繁忙,请稍后再试!”,“data”:null,“timestamp”:1632579332177}
通过观察得之,基于自定义注解同样实现了接口限流的效果。

6、小结
一般在系统上线时我们通过对系统压测可以评估出系统的性能阈值,然后给接口加上合理的限流参数,防止出现大流量请求时直接压垮系统。今天我们介绍了几种常见的限流算法(重点关注令牌桶算法),基于Guava工具类实现了接口限流并利用AOP完成了对限流代码的优化。
转载自风情客家__

  • 22
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值