Semaphore 信號量限流,這東西真管用嗎?

點藍色「小黑十一點半

👆關注樓下小黑哥😏


Hello,大家好,我是樓下小黑哥~

最近參與公司的服務治理項目,主要目的是爲了保證生產服務高可用性,以及高穩定性。

爲了更好的參與的這個項目,這段時間一直在充電學習這方面的相關知識,包括限流,熔斷,服務降級等等。

那在學習限流的時候,看到網上很多文章中直接使用了JDK 中 Semaphore 實現了限流器。

雖然到達的限流的目的,但是實際上其還是存在很大缺陷。

那你如果沒有經過完整測試,直接將這套限流方式照搬過來,發到了生產環境,那就等着背這口大鍋吧。

好了,今天我們主要來聊聊 Semaphore ,文章主要內容如下圖所示:

semaphore

限流的方式有很多,從類型上分類,一般可以分爲兩種:

  • 併發數限流
  • QPS 限流

併發數限流就是限制同一時刻的最大併發請求數,而 QPS 限流指的是限制一段時間內請求數。

那我們今天的講的 semaphore 限流其實屬於第一類,通過限制併發數,到達限流的目的。

semaphore中文翻譯爲信號量,它其實是併發領域中一個重要編程模型,幾乎所有支持併發編程的語言都支持信號量這個機制。

JDK 併發包下 Semaphore 類就是信號量的實現類,它的模型比較簡單,如下圖所示:

Semaphore 內部有一個計數器,我們使用的時候,需要提前初始化。

初始化之後,我們就可以調用 acquire方法,獲取信號量,這時計數器將會減 1。如果此時計數器值小於 0,則會將當前線程阻塞,並且加入到等待隊列,否則當前線程繼續執行。

執行結束之後,調用 release方法,釋放信號量,計數器將會加 1。那如果此時計數器值的小於或等於0,則會喚醒的等待隊列一個線程,然後將其移出隊列。

併發流量通過 Semaphore進行限流,只有拿到信號量才能繼續執行,保證後端資源訪問數總是在安全範圍。

Semaphore 限流

Semaphore 限流常見使用方式

瞭解完 Semaphore 基本原理之後,我們就來實現一個限流器。

public class ConcurrencyLimit {

    private Semaphore semaphore;


    private ConcurrencyLimit() {
    }

    public static ConcurrencyLimit create(int permits) {
        ConcurrencyLimit concurrencyLimit = new ConcurrencyLimit();
        concurrencyLimit.semaphore = new Semaphore(permits);
        return concurrencyLimit;
    }

    public void acquire() throws InterruptedException {
        this.semaphore.acquire();
    }

    public void release() {
        this.semaphore.release();
    }

    public boolean tryAcquire() {
        return this.semaphore.tryAcquire();
    }
}

限流器底層直接使用 Semaphore,我們寫個例子實際測試一下:

ConcurrencyLimit limit = ConcurrencyLimit.create(5);
ExecutorService executorService = Executors.newCachedThreadPool(
        new ThreadFactoryBuilder()
                .setNameFormat("limit-%d")
                .build());

for (int i = 0; i < 10; i++) {
    executorService.execute(() -> {
        try {
            limit.acquire();
            System.out.println(Thread.currentThread().getName() + " START");
            // 模擬內部耗時
            TimeUnit.MILLISECONDS.sleep(new Random().nextInt(500));
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + " END");
            limit.release();
        }
    });
}

碎碎念:這裏要注意了,Semaphoreacquire方法與 release方法,一定要成對出現。

如果調用 acquire,最後別忘了調用 release,可能會導致程序發生假死等詭異的情況。

輸出結果如下:

可以看到,同一時刻,最多隻有 5 個線程開始執行任務,起到限流了目的。

其實隨便搜下 Semaphore限流,可以看到實現方式跟上面差不多。

那這上面的限流實現真的沒問題嗎?

「可以說有,也可以說沒有,這主要還是要看限流器使用場景。」

Semaphore 限流缺陷

如果我們換一個場景,將這個限流器用在一個 Web 服務,我們來看下高併發情況下會有什麼問題。

@Slf4j
@Component
public class LimitInterceptor extends HandlerInterceptorAdapter {

    ConcurrencyLimit concurrencyLimit;
    public LimitInterceptor() {
        this.concurrencyLimit = ConcurrencyLimit.create(10);
    }
  
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        concurrencyLimit.acquire();
        return true;
    }
  
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        concurrencyLimit.release();
    }
}

如上代碼所示,我們在 SpringMVC 的攔截器中使用限流器。

任一請求執行的時候,首先將會經過 Interceptor攔截器中 preHandle 方法,在這裏面我們調用獲取信號量方法。

當請求邏輯完成之後,內部將會調用攔截器的 afterCompletion,我們在這裏釋放信號量。

在服務請求內,休眠 100ms,模擬內部接口耗時。

下面使用壓測神器 「jmeter」 同時發起 500 個併發請求,模擬高併發的情況。

壓測結果如下圖所示:

從報表數據可以看到,雖然我們內部耗時僅僅只有 100 毫秒,但是接口平均請求耗時已經到達了 「2.4s」,P99 的耗時更是到達了 「4.4s」

響應時間增長圖如下所示:

可以看到併發數越大,接口響應時間也越大。

如果這個限流器真的應用在生產環境,可能剛發佈上線的時候,流量比較小,接口響應一切正常。

後面一旦碰到請求數變大,接口響應時間將會拉長,然後客戶請求出現大規模的超時。

當壓力繼續增大,服務端可能就沒辦法再接受新的請求。


那爲什麼會這樣?

主要是因爲  Semaphore#acquire方法如果沒有獲取到信號量,是會阻塞線程的,然後線程進入等待隊列。

默認情況下  Semaphore 使用不公平鎖競爭,那在高併發請求下,線程競爭資源比較激烈,有的線程可能運氣比較好,直接拿到信號量,那這部分請求接口耗時將會是正常。

但是有部分線程可能運氣不佳,直接被阻塞,一直等到最後才能拿到信號量,才能執行。

優化 Semaphore 限流

我們目前使用的大多數服務,追求的就是一個「低延遲,高吞吐」,那這類服務到達限流線之後,就應該直接拒絕,響應響應錯誤信息,快速結束請求。

Semaphore 實際還提供另一個tryAcquire 方法,這個方法如果拿不到信號量,將會直接返回 false,比較符合這種場景。

下面優化一下上面的限流代碼,主要修改一下攔截器內 preHandle 使用的方法。

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    if (!concurrencyLimit.tryAcquire()) {
        response.getWriter().println("ERROR");
        return false;
    }
    return true;
}

那通過這種方式,我們可以快速返回錯誤信息,不用讓調用者一直等待。

再使用 jmeter 模擬高併發請求,結果如下:

可以看到這次響應時間就沒有上一次那麼誇張。

總結

一切拋開業務的架構設計都是耍流氓!

我們可以使用 Semaphore 快速實現一個限流器,不過使用過程一定注意使用場景,謹慎測試,切勿直接複製亂搬網上的代碼。

那像大多數的互聯網服務來講,快速響應纔是最重要的,所以限流一定不能使用 Semaphore#acquire阻塞式方法。

而像有些後臺離線服務,不追求快速響應,只需要完成即可,那這類我們可以使用 Semaphore#acquire,將線程阻塞直到完成任務。


往期 精彩回顧




別再這麼寫代碼了,這幾個方法不香嗎?
Dubbo 同步調用太慢,也許你可以試試異步處理
有些線程跑着跑着就不見了


本文分享自微信公衆號 - 程序通事(US_stocks)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章