高并发之使用RateLimiter、Semaphore对访问资源进行限流

1 Semaphore和RateLimiter

限流是保护高并发系统的三把利器之一,另外两个是缓存和降级。限流在很多场景中用来限制并发和请求量,比如说秒杀抢购,保护自身系统和下游系统不被巨型流量冲垮等。

限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务或进行流量整形。

常用的限流方式和场景有:

  • 限制总并发数(比如数据库连接池、线程池)
  • 限制瞬时并发数(如nginx的limitconn模块,用来限制瞬时并发连接数,Java的Semaphore也可以实现)
  • 限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limitreq模块,限制每秒的平均速率)
  • 其他还有如限制远程接口调用速率、限制MQ的消费速率。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流

比如说,我们需要限制方法被调用的并发数不能超过100(同一时间并发数),则我们可以用信号量 Semaphore实现。

可如果我们要限制方法在一段时间内平均被调用次数不超过100,则需要使用 RateLimiter。

RateLimiter 与 Semaphore区别

  • RateLimiter限制的是速率
  • Semaphore限制的是并发数

2 Semaphore(信号量,需要多线程使用)

Semaphore:是一种计数器,用来保护一个或者多个共享资源的访问。

通常我们叫它信号量, 可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。

常用于控制某一controller最大并发数,设置new Semaphore(5),意思是当前请求最大并发为5:
* 1、请求可以进来,但是没有资源时需要等待。
* 2、当前controller有资源时,开始处理当前请求。

2.1 导入依赖

位于package java.util.concurrent;,无需导入

2.2 方法解释

构造方法

  • permits:初始许可数,也就是最大访问线程数
  • fair:当设置为false时,创建的信号量为非公平模式;当设置为true时,信号量是公平模式
public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

public Semaphore(int permits, boolean fair) {
    sync = (fair)? new FairSync(permits) : new NonfairSync(permits);
}

主要方法

  • void acquire() :从信号量获取一个许可,如果无可用许可前将一直阻塞等待

  • void acquire(int permits) :获取指定数目的许可,如果无可用许可前也将会一直阻塞等待

  • void acquireUninterruptibly() :获取一个令牌,在获取到令牌之前线程一直处于阻塞状态(忽略中断)。

  • boolean tryAcquire():从信号量尝试获取一个许可,如果无可用许可,直接返回false,不会阻塞

  • boolean tryAcquire(int permits): 尝试获取指定数目的许可,如果无可用许可直接返回false

  • boolean tryAcquire(int permits, long timeout, TimeUnit unit): 在指定的时间内尝试从信号量中获取许可,如果在指定的时间内获取成功,返回true,否则返回false

  • void release(): 释放一个许可,别忘了在finally中使用,注意:多次调用该方法,会使信号量的许可数增加,达到动态扩展的效果,如:初始permits为1, 调用了两次release,最大许可会改变为2

  • int availablePermits(): 获取当前信号量可用的许可数量

2.3 Semaphore使用流程解析

1、Semaphore初始化

Semaphore semaphore=new Semaphore(2);
  1. 当调用new Semaphore(2) 方法时,默认会创建一个非公平的锁的同步阻塞队列。
  2. 把初始令牌数量赋值给同步队列的state状态,state的值就代表当前所剩余的令牌数量。

2、获取令牌

semaphore.acquire();
  1. 当前线程会尝试去同步队列获取一个令牌,获取令牌的过程也就是使用原子的操作去修改同步队列的state ,获取一个令牌则修改为state=state-1。

  2. 当计算出来的state<0,则代表令牌数量不足,此时会创建一个Node节点加入阻塞队列,挂起当前线程。

  3. 当计算出来的state>=0,则代表获取令牌成功。

源码:

/**
 *  获取1个令牌
 */
public void acquire() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}


/**
 * 共享模式下获取令牌,获取成功则返回,失败则加入阻塞队列,挂起线程
 * @param arg
 * @throws InterruptedException
 */
public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    //尝试获取令牌,arg为获取令牌个数,当可用令牌数减当前令牌数结果小于0,则创建一个节点加入阻塞队列,挂起当前线程。
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}


/**
 * 1、创建节点,加入阻塞队列,
 * 2、重双向链表的head,tail节点关系,清空无效节点
 * 3、挂起当前节点线程
 * @param arg
 * @throws InterruptedException
 */
private void doAcquireSharedInterruptibly(int arg)
    throws InterruptedException {
    //创建节点加入阻塞队列
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            //获得当前节点pre节点
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);//返回锁的state
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
            }
            //重组双向链表,清空无效节点,挂起当前线程
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

3、释放令牌

semaphore.release();

当调用semaphore.release() 方法时

  1. 线程会尝试释放一个令牌,释放令牌的过程也就是把同步队列的state修改为state=state+1的过程

  2. 释放令牌成功之后,同时会唤醒同步队列中的一个线程。

  3. 被唤醒的节点会重新尝试去修改state=state-1 的操作,如果state>=0则获取令牌成功,否则重新进入阻塞队列,挂起线程。

源码:

/**
 * 释放令牌
 */
public void release() {
    sync.releaseShared(1);
}

/**
 *释放共享锁,同时会唤醒同步队列中的一个线程。
 * @param arg
 * @return
 */
public final boolean releaseShared(int arg) {
    //释放共享锁
    if (tryReleaseShared(arg)) {
        //唤醒所有共享节点线程
        doReleaseShared();
        return true;
    }
    return false;
}

/**
 * 唤醒同步队列中的一个线程
 */
private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {//是否需要唤醒后继节点
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))//修改状态为初始0
                    continue;
                unparkSuccessor(h);//唤醒h.nex节点线程
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE));
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

2.4 使用示例

@Slf4j
public class MySemaphore {

    public static void main(String[] args) {
        Eat();
    }

    /**
     * 常用于控制某一controller最大并发数,
     * 设置new Semaphore(5),意思是当前请求最大并发为5
     * 1、请求可以进来,但是没有资源时需要等待。
     * 2、当前controller有资源时,开始处理当前请求。
     * <p>
     * 模拟食堂窗口打饭
     */
    public static void Eat() {

        // 一共有三个窗口
        Semaphore semaphore = new Semaphore(3);

        // 10个同学来打饭
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                try {
                    log.info("====" + Thread.currentThread().getName() + "来到食堂窗口====");
                    if (semaphore.availablePermits() == 0) {
                        log.info("等待前面同学打饭ing...");
                    }
                    // 如果获取不到令牌则阻塞线程
                    semaphore.acquire();
                    log.info(Thread.currentThread().getName() + "成功打饭");
                    // 模拟打饭时间
                    Thread.sleep(1000 * 2);
                    log.info(Thread.currentThread().getName() + "离开食堂");
                    //释放令牌,让其他线程进入
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, i + "同学");

            thread.start();

        }
    }

}


2.5 运行结果

在这里插入图片描述

3 RateLimiter(限流器,适用于单、多线程)

RateLimiter是一个基于令牌桶算法实现的限流器,常用于控制网站的QPS。

与Semaphore不同,Semaphore控制的是某一时刻的访问量,RateLimiter控制的是某一时间间隔的访问量。

通常可应用于

  • 抢购限流防止冲垮系统;
  • 限制某接口、服务单位时间内的访问量,譬如一些第三方服务会对用户访问量进行限制;
  • 限制网速,单位时间内只允许上传下载多少字节等。

3.1 导入依赖

使用Guava RateLimiter
Guava是Java领域优秀的开源项目,它包含了Google在Java项目中使用一些核心库,包含集合(Collections),缓存(Caching),并发编程库(Concurrency),常用注解(Common annotations),String操作,I/O操作方面的众多非常实用的函数。

Guava的 RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。

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

3.2 方法解释

构造方法

  • 根据指定的稳定吞吐率创建限流器,这里的吞吐率是指每秒多少许可书(通常是QPS、每秒多少个查询)

    public static RateLimiter create(double permitsPerSecond)
    
  • 根据指定的稳定吞吐率和预热器来创建限流器,这里的吞吐率是指每秒多少许可书(通常是QPS、每秒多少个请求量),在这段预热时间内,RateLimiter 每秒分配的许可书会平稳的增长直到预热期结束时达到其最大速率。(前提是请求足够,能够使其饱和)。
    通俗来讲就是warmupPeriod秒内创建permitsPerSecond个许可。

    public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)
    

主要方法

  • 从RateLimiter 获取一个许可,该方法会阻塞直到获取到请求

    public double acquire()
    
  • 从RateLimiter 获取指定许可数,该方法会阻塞直到获取到请求

    public double acquire(int permits) 
    
  • 从RateLimiter获取许可,如果该许可可以在无延迟情况下立即获取到的话

    public boolean tryAcquire()
    
  • 从RateLimiter获取permits个许可,如果该许可可以在无延迟情况下立即获取到的话

    public boolean tryAcquire(int permits)
    
  • 在timeout时间内从RateLimiter获取permits个许可,如果无法在指定时间内获取到则会返回false(无需等待)

    public boolean tryAcquire(int permits, long timeout, TimeUnit unit)
    
  • 更新RateLimiter的稳定速率,参数permitsPerSecond由构造RateLimiter的工厂方法提供

    public final void setRate(double permitsPerSecond)
    
  • 返回RateLimiter配置中的稳定速率,该速率单位是每秒多少个许可数

    public final double getRate()
    

3.3 平滑突发限流和平滑预热限流

// RateLimiter提供了两个工厂方法,最终会调用下面两个函数,生成RateLimiter的两个子类。
static RateLimiter create(SleepingStopwatch stopwatch, double permitsPerSecond) {
 RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
    rateLimiter.setRate(permitsPerSecond);
 return rateLimiter;
}
static RateLimiter create(
 SleepingStopwatch stopwatch, double permitsPerSecond, long warmupPeriod, TimeUnit unit,
 double coldFactor) {
 RateLimiter rateLimiter = new SmoothWarmingUp(stopwatch, warmupPeriod, unit, coldFactor);
    rateLimiter.setRate(permitsPerSecond);
 return rateLimiter;
}

平滑突发限流

使用 RateLimiter的静态方法创建一个限流器,设置每秒放置的令牌数为5个。返回的RateLimiter对象可以保证1秒内不会给超过5个令牌,并且以固定速率进行放置,达到平滑输出的效果。

public void testSmoothBursty() {
	 RateLimiter r = RateLimiter.create(5);
	 while (true) {
	 	System.out.println("get 1 tokens: " + r.acquire() + "s");
 	 }
}
 	/**
     * output: 基本上都是0.2s执行一次,符合一秒发放5个令牌的设定。
     * get 1 tokens: 0.0s 
     * get 1 tokens: 0.182014s
     * get 1 tokens: 0.188464s
     * get 1 tokens: 0.198072s
     * get 1 tokens: 0.196048s
     * get 1 tokens: 0.197538s
     * get 1 tokens: 0.196049s
     */

RateLimiter使用令牌桶算法,会进行令牌的累积,如果获取令牌的频率比较低,则不会导致等待,直接获取令牌。

public void testSmoothBursty2() {
	RateLimiter r = RateLimiter.create(2);
	while (true)
	{
	   System.out.println("get 1 tokens: " + r.acquire(1) + "s");
	try {
	   Thread.sleep(2000);
	} catch (Exception e) {}
	System.out.println("get 1 tokens: " + r.acquire(1) + "s");
	System.out.println("get 1 tokens: " + r.acquire(1) + "s");
	System.out.println("get 1 tokens: " + r.acquire(1) + "s");
	System.out.println("end");
	 /**
	      * output:
	      * get 1 tokens: 0.0s
	      * get 1 tokens: 0.0s
	      * get 1 tokens: 0.0s
	      * get 1 tokens: 0.0s
	      * end
	      * get 1 tokens: 0.499796s
	      * get 1 tokens: 0.0s
	      * get 1 tokens: 0.0s
	      * get 1 tokens: 0.0s
	      */
	}
}

RateLimiter由于会累积令牌,所以可以应对突发流量。在下面代码中,有一个请求会直接请求5个令牌,但是由于此时令牌桶中有累积的令牌,足以快速响应。 RateLimiter在没有足够令牌发放时,采用滞后处理的方式,也就是前一个请求获取令牌所需等待的时间由下一次请求来承受,也就是代替前一个请求进行等待。

public void testSmoothBursty3() {
	RateLimiter r = RateLimiter.create(5);
	while (true)
	{
	   System.out.println("get 5 tokens: " + r.acquire(5) + "s");
	   System.out.println("get 1 tokens: " + r.acquire(1) + "s");
	   System.out.println("get 1 tokens: " + r.acquire(1) + "s");
	   System.out.println("get 1 tokens: " + r.acquire(1) + "s");
	   System.out.println("end");
		/**
	      * output:
	      * get 5 tokens: 0.0s
	      * get 1 tokens: 0.996766s 滞后效应,需要替前一个请求进行等待
	      * get 1 tokens: 0.194007s
	      * get 1 tokens: 0.196267s
	      * end
	      * get 5 tokens: 0.195756s
	      * get 1 tokens: 0.995625s 滞后效应,需要替前一个请求进行等待
	      * get 1 tokens: 0.194603s
	      * get 1 tokens: 0.196866s
	    */
	}
}

平滑预热限流

每三秒生成两个令牌

RateLimiterSmoothWarmingUp是带有预热期的平滑限流,它启动后会有一段预热期,逐步将分发频率提升到配置的速率。 比如下面代码中的例子,创建一个平均分发令牌速率为2,预热期为3秒。由于设置了预热时间是3秒,令牌桶一开始并不会0.5秒发一个令牌,而是形成一个平滑线性下降的坡度,频率越来越高,在3秒钟之内达到原本设置的频率,以后就以固定的频率输出。这种功能适合系统刚启动需要一点时间来“热身”的场景。

public void testSmoothwarmingUp() {

	// 每三秒生成两个令牌
	RateLimiter r = RateLimiter.create(2, 3, TimeUnit.SECONDS);
	while (true)
	{
		 System.out.println("get 1 tokens: " + r.acquire(1) + "s");
		 System.out.println("get 1 tokens: " + r.acquire(1) + "s");
		 System.out.println("get 1 tokens: " + r.acquire(1) + "s");
		 System.out.println("get 1 tokens: " + r.acquire(1) + "s");
		 System.out.println("end");
 	 /**
       * output:
       * get 1 tokens: 0.0s
       * get 1 tokens: 1.329289s
       * get 1 tokens: 0.994375s
       * get 1 tokens: 0.662888s  上边三次获取的时间相加正好为3秒
       * end
       * get 1 tokens: 0.49764s  正常速率0.5秒一个令牌
       * get 1 tokens: 0.497828s
       * get 1 tokens: 0.49449s
       * get 1 tokens: 0.497522s
       */
	}
}

3.4 使用示例

@Slf4j
public class MyRateLimiter {

    public static void main(String[] args) {
        test1();
    }

    public static void test1() {
        // 新建一个每秒限制3个的令牌桶
        RateLimiter rateLimiter = RateLimiter.create(3);
        // 每秒只能执行三次(qps=3)
        for (int i = 0; i < 20; i++) {
            //阻塞直到可以授予请求,acquire是距离上次执行的时间
            double acquire = rateLimiter.acquire();
            log.info("发送了短信:" + i);
        }
    }

    public static void test2() {
        //新建一个每秒限制3个的令牌桶
        RateLimiter rateLimiter = RateLimiter.create(3.0);
        ExecutorService executor = Executors.newFixedThreadPool(16);
        for (int i = 0; i < 10; i++) {
            executor.execute(() -> {
                //获取令牌桶中一个令牌,最多等待10秒
                boolean res = rateLimiter.tryAcquire(1, 10, TimeUnit.SECONDS);
                if (res) {
                    log.info("得到了许可");
                }
            });
        }
        executor.shutdown();
    }

}

参考文章

超详细的Guava RateLimiter限流原理解析
https://www.cnblogs.com/wxgblogs/p/5422508.html
https://zhuanlan.zhihu.com/p/98593407
https://zhuanlan.zhihu.com/p/69986837
https://zhuanlan.zhihu.com/p/60979444

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

L-960

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值