☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

李浩宇Alex 發表於 2021-09-17
Java MIT

併發程式設計的三劍客

在開發高併發系統時有三劍客:快取、降級和限流。

  • 快取 快取的目的是提升系統訪問速度和增大系統處理容量。
  • 降級 降級是當服務出現問題或者影響到核心流程時,需要暫時遮蔽掉,待高峰或者問題解決後再開啟。
  • 限流 限流的目的是通過對併發訪問/請求進行限速,或者對一個時間視窗內的請求進行限速來保護系統,一旦達到限制速率則可以拒絕服務、排隊或等待、降級等處理。

限流的思想

溢位思想:

就是用一個固定大小的佇列。比如設定限流為5qps,1s可以接受5個請求;那我們就造一個大小為5的佇列,如果佇列為滿了,就拒絕請求;如果佇列未滿,就往佇列新增請求。

速度控制

令牌聽起來挺酷的。以固定的速率往桶裡發放令牌。然後消費者每次要取到令牌(acquire)才可以響應請求,控制速率呢,我們通過控制消費者的消費速率是5qps,1s消費5個即可。

限流的演算法

常用的限流演算法有兩種:漏桶演算法和令牌桶演算法及滑動視窗(計數器)演算法等。

計數限流演算法

無論固定視窗還是滑動視窗核心均是對請求進行計數,區別僅僅在於對於計數時間區間的處理。

固定視窗計數

☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

實現原理
  • 固定視窗計數法思想比較簡單,只需要確定兩個引數:計數週期T及週期內最大訪問(呼叫)數N。請求到達時使用以下流程進行操作:

  • 固定視窗計數實現簡單,並且只需要記錄上一個週期起始時間與週期內訪問總數,幾乎不消耗額外的儲存空間。

演算法缺陷

固定視窗計數缺點也非常明顯,在進行週期切換時,上一個週期的訪問總數會立即置為0,這可能導致在進行週期切換時可能出現流量突發

令牌桶演算法

令牌桶演算法的原理:系統會以一個恆定的速度往桶裡放入令牌,而如果請求需要被處理,則需要先從桶裡獲取一個令牌,當桶裡沒有令牌可取時,則拒絕服務。

☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

  • 令牌桶演算法則是一個存放固定容量令牌的桶,按照固定速率往桶裡新增令牌。
  • 桶中存放的令牌數有最大上限,超出之後就被丟棄或者拒絕。
  • 當流量或者網路請求到達時,每個請求都要獲取一個令牌,如果能夠獲取到,則直接處理,並且令牌桶刪除一個令牌。
  • 如果獲取不到,該請求就要被限流,要麼直接丟棄,要麼在緩衝區等待。
優點

由於令牌是固定間隔發放的,假設還是5qps,如果我有1s內沒有請求,我的令牌桶就滿了,可以一瞬間響應5個請求(一次過取5個令牌),也就是可以應對瞬時流量。

☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

漏桶演算法

  • 漏桶演算法思路很簡單,水(請求)先進入到漏桶裡,漏桶以一定的速度出水,當水流入速度過大會直接溢位,可以看出漏桶演算法能強行限制資料的傳輸速率。
    ☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

  • 如上圖就像一個漏斗一樣,進來的水量就好像訪問流量一樣,而出去的水量就像是我們的系統處理請求一樣。

  • 當訪問流量過大時,這個漏斗中就會積水,如果水太多了就會溢位。

漏桶演算法的實現往往依賴於佇列,請求到達如果佇列未滿則直接放入佇列,然後有一個處理器按照固定頻率從佇列頭取出請求進行處理。如果請求量大,則會導致佇列滿,那麼新來的請求就會被拋棄。

令牌桶和漏桶對比

  • 令牌桶是按照固定速率往桶中新增令牌,請求是否被處理需要看桶中令牌是否足夠,當令牌數減為零時則拒絕新的請求;漏桶則是按照常量固定速率流出請求,流入請求速率任意,當流入的請求數累積到漏桶容量時,則新流入的請求被拒絕;

  • 令牌桶限制的是平均流入速率,允許突發請求,只要有令牌就可以處理,支援一次拿3個令牌,4個令牌;

  • 漏桶限制的是常量流出速率,即流出速率是一個固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2,從而平滑突發流入速率;

  • 令牌桶允許一定程度的突發,而漏桶主要目的是平滑流出速率;

除了要求能夠限制資料的平均傳輸速率外,還要求允許某種程度的突發傳輸。這時候漏桶演算法可能就不合適了,令牌桶演算法更為適合。

訊號量的應用

  • 作業系統的訊號量是個很重要的概念,Java 併發庫 的Semaphore 可以很輕鬆完成訊號量控制,Semaphore可以控制某個資源可被同時訪問的個數,通過 acquire() 獲取一個許可,如果沒有就等待,而 release() 釋放一個許可。

  • 訊號量的本質是控制某個資源可被同時訪問的個數,在一定程度上可以控制某資源的訪問頻率,但不能精確控制。

限流的思想

Guava中的RateLimiter可以限制單程式中某個方法的速率,本文主要介紹如何使用,實現原理請參考文件:推薦:超詳細的Guava RateLimiter限流原理解析和推薦:RateLimiter 原始碼分析(Guava 和 Sentinel 實現)。

Guava RateLimiter

Google開源工具包Guava提供了限流工具類RateLimiter,該類基於令牌桶演算法實現流量限制,使用十分方便。

原理:Guava RateLimiter基於令牌桶演算法,

  • RateLimiter系統限制QPS是多少,那麼RateLimiter將以這個速度往桶裡面放入令牌。
  • 然後請求的時候,通過tryAcquire()方法向RateLimiter獲取許可(令牌)。

Guava RateLimiter 控制操作

Guava RateLimiter 限速手段

  • RateLimiter從概念上來講,速率限制器會在可配置的速率下分配許可證。如果必要的話,每個acquire() 會阻塞當前執行緒直到許可證可用後獲取該許可證。一旦獲取到許可證,不需要再釋放許可證。
  • RateLimiter通過限制後面請求的等待時間,來支援一定程度的突發請求(預消費)。

Maven配置

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

Java簡單案例

public class RateLimiterService {
    // 每秒發出5個令牌
    RateLimiter rateLimiter = RateLimiter.create(5);
    /**
     * 嘗試獲取令牌
     */
    public boolean tryAcquire() {
        return rateLimiter.tryAcquire();
    }
	public  void acquire() {
        rateLimiter.acquire();
    }
   public static void main(String[] args){
        if (accessLimitService.tryAcquire()) {
            log.info("start");
            // 模擬業務執行500毫秒
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "access success [" + LocalDateTime.now() + "]";
        } else {
            //log.warn("限流");
            return "access limit [" + LocalDateTime.now() + "]";
        }
      }
}

public void testMethod(){
	ExecutorService pool = Executors.newFixedThreadPool(10);
        RateLimiter rateLimiter = RateLimiter.create(5); // rate is "5 permits per second"
        IntStream.range(0, 10).forEach(i -> pool.submit(() -> {
            if (rateLimiter.tryAcquire()) {
                try {
                    log.info("start");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                }
            } else {
                log.warn("限流");
            }
        }));

public void testMethod2(){
	ExecutorService pool = Executors.newFixedThreadPool(10);
        RateLimiter rateLimiter = RateLimiter.create(5); // rate is "5 permits per second"
        IntStream.range(0, 10).forEach(i -> pool.submit(() -> {
            rateLimiter.acquire();
            log.info("start");
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }));
        pool.shutdown();
      }
  }
}
public class GuavaRateLimiter {
    public static ConcurrentHashMap<String, RateLimiter> resourceRateLimiter = new ConcurrentHashMap<String, RateLimiter>();
    //初始化限流工具RateLimiter
    static {
        createResourceRateLimiter("order", 50);
    }
    public static void createResourceRateLimiter(String resource, double qps) {
        if (resourceRateLimiter.contains(resource)) {
            resourceRateLimiter.get(resource).setRate(qps);
        } else {
            //建立限流工具,每秒發出50個令牌指令
            RateLimiter rateLimiter = RateLimiter.create(qps);
            resourceRateLimiter.putIfAbsent(resource, rateLimiter);
        }
    }
    public static void main(String[] args) {
        for (int i = 0; i < 5000; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    //如果獲得令牌指令,則執行業務邏輯
                    if (resourceRateLimiter.get("order").tryAcquire(10, TimeUnit.MICROSECONDS)) {
                        System.out.println("執行業務邏輯");
                    } else {
                        System.out.println("限流");
                    }
                }
            }).start();
        }
    }
}

方法摘要

限流及建立方法

☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)
☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

create方法
public static RateLimiter create(double permitsPerSecond)

根據指定的穩定吞吐率建立RateLimiter,這裡的吞吐率是指每秒多少許可數(通常是指QPS,每秒多少查詢)。

The returned RateLimiter ensures that on average no more than permitsPerSecond are issued during any given second, with sustained requests being smoothly spread over each second. When the incoming request rate exceeds permitsPerSecond the rate limiter will release one permit every (1.0 / permitsPerSecond) seconds. When the rate limiter is unused, bursts of up to permitsPerSecond permits will be allowed, with subsequent requests being smoothly limited at the stable rate of permitsPerSecond.

返回的RateLimiter
  • 確保了在平均情況下,每秒釋出的許可數不會超過permitsPerSecond,每秒鐘會持續傳送請求。

  • 當傳入請求速率超過permitsPerSecond,速率限制器會每秒釋放一個許可(1.0 / permitsPerSecond 這裡是指設定了permitsPerSecond為1.0) 。

  • 當速率限制器閒置時,允許許可數暴增到permitsPerSecond,隨後的請求會被平滑地限制在穩定速率permitsPerSecond中。

引數:
  • permitsPerSecond – 返回的RateLimiter的速率,意味著每秒有多少個許可變成有效。
  • 丟擲:
  • IllegalArgumentException – 如果permitsPerSecond為負數或者為0
public static RateLimiter create(double permitsPerSecond,long warmupPeriod,TimeUnit unit)

根據指定的穩定吞吐率和預熱期來建立RateLimiter,這裡的吞吐率是指每秒多少許可數(通常是指QPS,每秒多少查詢),在這段預熱時間內,RateLimiter每秒分配的許可數會平穩地增長直到預熱期結束時達到其最大速率(只要存在足夠請求數來使其飽和)。同樣地,如果RateLimiter 在warmupPeriod時間內閒置不用,它將會逐步地返回冷卻狀態。也就是說,它會像它第一次被建立般經歷同樣的預熱期。返回的RateLimiter 主要用於那些需要預熱期的資源,這些資源實際上滿足了請求(比如一個遠端服務),而不是在穩定(最大)的速率下可以立即被訪問的資源。返回的RateLimiter 在冷卻狀態下啟動(即預熱期將會緊跟著發生),並且如果被長期閒置不用,它將回到冷卻狀態。

引數:
  • permitsPerSecond – 返回的RateLimiter的速率,意味著每秒有多少個許可變成有效。
  • warmupPeriod – 在這段時間內RateLimiter會增加它的速率,在抵達它的穩定速率或者最大速率之前
  • unit – 引數warmupPeriod 的時間單位
丟擲:
  • IllegalArgumentException – 如果permitsPerSecond為負數或者為0

限流及阻塞方法

acquire

☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)
☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)
☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

public double acquire()

從RateLimiter獲取一個許可,該方法會被阻塞直到獲取到請求。如果存在等待的情況的話,告訴呼叫者獲取到該請求所需要的睡眠時間。該方法等同於acquire(1)。

返回:

time spent sleeping to enforce rate, in seconds; 0.0 if not rate-limited
執行速率的所需要的睡眠時間,單位為妙;如果沒有則返回0

acquire
public double acquire(int permits)

從RateLimiter獲取指定許可數,該方法會被阻塞直到獲取到請求數。如果存在等待的情況的話,告訴呼叫者獲取到這些請求數所需要的睡眠時間。

引數:
  • permits – 需要獲取的許可數
返回:
  • 執行速率的所需要的睡眠時間,單位為妙;如果沒有則返回0
丟擲:
  • IllegalArgumentException – 如果請求的許可數為負數或者為0

tryAcquire

public boolean tryAcquire(long timeout,TimeUnit unit)

從RateLimiter獲取許可如果該許可可以在不超過timeout的時間內獲取得到的話,或者如果無法在timeout 過期之前獲取得到許可的話,那麼立即返回false(無需等待)。該方法等同於tryAcquire(1, timeout, unit)。

引數:
  • timeout – 等待許可的最大時間,負數以0處理
  • unit – 引數timeout 的時間單位
返回:
  • true表示獲取到許可,反之則是false
丟擲:
  • IllegalArgumentException – 如果請求的許可數為負數或者為0

tryAcquire

public boolean tryAcquire(int permits,long timeout,TimeUnit unit)

從RateLimiter 獲取指定許可數如果該許可數可以在不超過timeout的時間內獲取得到的話,或者如果無法在timeout 過期之前獲取得到許可數的話,那麼立即返回false (無需等待)。

引數:
  • permits – 需要獲取的許可數
  • timeout – 等待許可數的最大時間,負數以0處理
  • unit – 引數timeout 的時間單位
返回:
  • true表示獲取到許可,反之則是false

限流及狀態設定

☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)
☕【Java技術指南】「併發程式設計專題」針對於Guava RateLimiter限流器的入門到精通(含實戰開發技巧)

public final void setRate(double permitsPerSecond)

更新RateLimite的穩定速率,引數permitsPerSecond 由構造RateLimiter的工廠方法提供。呼叫該方法後,當前限制執行緒不會被喚醒,因此他們不會注意到最新的速率;只有接下來的請求才會。需要注意的是,由於每次請求償還了(通過等待,如果需要的話)上一次請求的開銷,這意味著緊緊跟著的下一個請求不會被最新的速率影響到,在呼叫了setRate 之後;它會償還上一次請求的開銷,這個開銷依賴於之前的速率。RateLimiter的行為在任何方式下都不會被改變,比如如果 RateLimiter 有20秒的預熱期配置,在此方法被呼叫後它還是會進行20秒的預熱。

引數:
  • permitsPerSecond – RateLimiter的新的穩定速率
丟擲:
  • IllegalArgumentException – 如果permitsPerSecond為負數或者為0
public final double getRate()

返回RateLimiter 配置中的穩定速率,該速率單位是每秒多少許可數。它的初始值相當於構造這個RateLimiter的工廠方法中的引數permitsPerSecond ,並且只有在呼叫setRate(double)後才會被更新。