服務限流,怎麼計算配額?

來源:https://zhenbianshu.github.io

問題

請求被限流

我們服務使用 Hystrix 進行服務限流,使用的是信號量方式,並根據接口的響應時間和服務的峯值 QPS 設置了限流的配額。

限流配額的計算方式爲:

我們接口單機單個接口的峯值 QPS 爲 1000,平均影響時長 15ms,我們認爲 Hystrix 的信號量是併發量,那麼一個信號量在一秒內能允許 1000ms/15ms~66 個請求通過,那麼服務 1000QPS 配置 15 個信號量就足夠了。

當然這是在忽略上下文切換和 GC 時間的情況下,考慮上這些因素,每個併發量每秒能服務的時長約爲 900ms,用同樣的公式計算所需要的信號量是 17,爲了應付突發流量,我將這個值設置爲了 30。

本以爲這樣就高枕無憂了,沒想到看錯誤日誌中偶然發現了有報錯:

HystrixRuntimeException occurred! , failureType:REJECTED_SEMAPHORE_EXECUTION, message:apiHystrixKey could not acquire a semaphore for execution and fallback failed.

我把信號量配置提高到了 50,沒想到還是沒看到問題有明顯好轉,這就比較詭異了。

解決

排查步驟

首先我列了一下排查的步驟,也整理一下出現這種問題的可能。

Jdk 的 Bug ?

從整體上看不出來,就只好從微觀時間點上看了,可這個問題出現是一瞬間的事,jstack 也無能爲力,雖然 jmc 倒是合適,但它部署有點費勁,而且還會在觀察的時候影響到服務,於是優先從歷史時間點上排查。

從錯誤日誌裏找了一個服務拒絕數校多的時間點,再觀察服務當時的狀態。錯誤日誌上除了一些請求被拒絕的報錯外就沒有其他的了,但我在 gclog 裏發現了奇怪的日誌。

2020-03-17T13:01:26.281+0800: 89732.109: Application time: 2.1373599 seconds
2020-03-17T13:01:26.308+0800: 89732.136: Total time for which application threads were stopped: 0.0273134 seconds, Stopping threads took: 0.0008935 seconds
2020-03-17T13:01:26.310+0800: 89732.137: Application time: 0.0016111 seconds
2020-03-17T13:01:26.336+0800: 89732.163: [GC (Allocation Failure) 2020-03-17T13:01:26.336+0800: 89732.164: [ParNew
Desired survivor size 429490176 bytes, new threshold 4 (max 4)
- age 1: 107170544 bytes, 107170544 total
- age 2: 38341720 bytes, 145512264 total
- age 3: 6135856 bytes, 151648120 total
- age 4: 152 bytes, 151648272 total
: 6920116K->214972K(7549760K), 0.0739801 secs] 9292943K->2593702K(11744064K), 0.0756263 secs] [Times: user=0.65 sys=0.23, real=0.08 secs]
2020-03-17T13:01:26.412+0800: 89732.239: Total time for which application threads were stopped: 0.1018416 seconds, Stopping threads took: 0.0005597 seconds
2020-03-17T13:01:26.412+0800: 89732.239: Application time: 0.0001873 seconds
2020-03-17T13:01:26.438+0800: 89732.265: [GC (GCLocker Initiated GC) 2020-03-17T13:01:26.438+0800: 89732.265: [ParNew
Desired survivor size 429490176 bytes, new threshold 4 (max 4)
- age 1: 77800 bytes, 77800 total
- age 2: 107021848 bytes, 107099648 total
- age 3: 38341720 bytes, 145441368 total
- age 4: 6135784 bytes, 151577152 total
: 217683K->215658K(7549760K), 0.0548512 secs] 2596413K->2594388K(11744064K), 0.0561721 secs] [Times: user=0.49 sys=0.18, real=0.05 secs]
2020-03-17T13:01:26.495+0800: 89732.322: Total time for which application threads were stopped: 0.0824542 seconds, Stopping threads took: 0.0005238 seconds

我看到連續發生了兩次 YGC,它們之間的間隔才 0.0001873s,可以認爲是進行了一次很長時間的 GC,總耗時達到了 160ms。再仔細觀察第二次 GC 時的內存分佈,可以看到它作爲一次 ParNew GC,發生時 eden 區的內存才使用了 200M,這就不符合常理了。

再看 GC 發生的原因,日誌裏標識的是 GCLocker Initiated GC。在使用 JNI 操作字符串或數組時,爲了防止 GC 導致數組指針發生偏移,JVM 實現了 GCLocker,它會在發生 GC 的時候阻止程序進入臨界區,並在最後一個臨界區內的線程退出時,發生一次 GCLocker GC。46 張 PPT 弄懂 JVM 這個分享給你。

至於這次的 GC,是 JDK 的一個 Bug,JDK-8048556 ,而我們的 Java 版本低於修復版本,出現這種問題實屬正常,可是,這個問題就歸究於 jdk 的 bug 嗎?升級了 jdk 版本就一定會好嗎?

“平均” 的陷阱

重新來計算一下,即使 JVM 每秒都有 160ms 在進行 GC,可系統有服務時間也還有 840ms,使用上文中的公式,信號量的還是完全足夠的。

一時想不明白,出去倒了杯水,走了走,忽然想到原來自己站錯了角度。我一直用秒作爲時間的基本單位,用一秒的平均狀態來代表系統的整體狀態,認爲一整秒內如果沒有問題,服務就不應該會發生問題,可是忽略了時間從來不是一秒一秒進行的。

試想,如果平穩運行的服務,忽然發生了一次 160ms 的 GC,那麼這 160ms 內的請求會平均分配到剩餘 840ms 內嗎?並不會,它們會擠在第 161ms 一次發送過來,而我們設置的信號量限制會作出什麼反應呢?

@Override
public boolean tryAcquire() {
    int currentCount = count.incrementAndGet();
    if (currentCount > numberOfPermits.get()) {
        count.decrementAndGet();
        return false;
    } else {
        return true;
    }
}

上面是 Hystrix 源碼中獲取信號量的代碼,可以發現,代碼裏沒有任何 block,如果當前使用的信號量大於配置值,就會直接拒絕。

這樣就說得通了,如果進行了 160ms 的 GC,再加上請求處理的平均耗時是 15ms,那系統就有可能在瞬間堆積 1000q/s * 0.175s = 175 的請求,如果信號量不足,請求就會被直接拒絕了。

也就是說即使 jdk 的 bug 修復了,信號量限制最少還是要設置爲 95 纔不會拒絕請求。

限流配額的正確計算方式

概念

那麼限流配額的正確計算方式是怎樣的呢?

在此之前我們要明確設置的限流配額都是併發量,它的單位是 ,這一點要區分於我們常用的服務壓力指標 QPS,因爲 QPS 是指一秒內的請求數,它的單位是 個/S,由於單位不同,它們是不能直接比較的,需要併發量再除以一個時間單位纔可以。

正確的公式應當是 併發量(個)/單個請求耗時(s) > QPS(個/s)

但由於 Java GC 的特性,我們不得不考慮 GC 期間請求堆積的可能,要處理這種情況,第一種是直接拒絕,像 Hystrix 的實現(有點坑),第二種是做一些緩衝。

信號量緩衝

其實信號量並不是無法做緩衝的,只是 Hystrix 內的” 信號量” 是自己實現的,比較 low。

比較” 正統” 的方式是使用 jdk 裏的 java.util.concurrent.Semaphore,它獲取信號量有兩種方式,第一種是 tryAcquire(),這類似於 Hystrix 的實現,是不會 block 的,如果當前信號量被佔用或不足,會返回 false。

第二種是使用 acquire() 方法,它沒有返回值,意思是方法只有在拿到信號量時纔會返回,而這個時間是不確定的。

我猜想這可能也是 Hystrix 不採用這種方式的原因,畢竟如果使用 FairSync 會有很多拿到信號量發現接口超時再拋棄的行爲,而使用 UnFairSync 又會使接口的影響時長無法確定。

線程池緩衝

線程池的緩衝比信號量要靈活得多,設置更大的 maximumPoolSizeBlockingQueue 都可以,設置 rejectHandler 也是很好的辦法。

只是使用線程池會有上下文切換的損耗,而且應對突發流量時,線程池的擴容也比較拙技。

考慮到它的靈活性,以及可以通過 Future.get() 的超時時間來控制接口的最大響應時間,和信號量比,沒有哪一種方式更好。

小結

解決了一個服務隱藏了很久的問題,又積累了排查此類問題的經驗,得到了問題不能只從一個角度看待的教訓,還是比較開心的。

當然,也又一次證明了看源碼的重要性,遇到問題追一追源碼,總會有些收益。另外,關注公衆號 Java 技術棧,在後臺回覆:面試,可以獲取我整理的 Java 系列面試題和答案,非常齊全。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/4Q1QrJHTLerR8K0Xv73_cg