點藍色「小黑十一點半」
👆關注樓下小黑哥😏
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();
}
});
}
碎碎念:這裏要注意了,
Semaphore
的acquire
方法與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源創計劃”,歡迎正在閱讀的你也加入,一起分享。