hello,大家好呀,我是小樓。
最近無聊(摸)閒逛(魚)github時,發現了一個阿里開源專案可以貢獻程式碼的地方。
不是寫單測、改程式碼格式那種,而是比較有挑戰的效能優化
,最關鍵的是還不難,仔細看完本文後,有點基礎就能寫出來的那種,話不多說,發車!
相信大家在日常寫程式碼獲取時間戳時,會寫出如下程式碼:
long ts = System.currentTimeMillis();
讀者中還有一些Gopher,我們用Go也寫一遍:
UnixTimeUnitOffset = uint64(time.Millisecond / time.Nanosecond)
ts := uint64(time.Now().UnixNano()) / UnixTimeUnitOffset
在一般情況下這麼寫,或者說在99%的情況下這麼寫一點問題都沒有,但有位大佬研究了Java下時間戳的獲取:
http://pzemtsov.github.io/2017/07/23/the-slow-currenttimemillis.html
他得出了一個結論:併發越高,獲取時間戳越慢!
具體到細節我們也不是很懂,大概原因是由於只有一個全域性時鐘源,高併發或頻繁訪問會造成嚴重的爭用。
快取時間戳
我最早接觸到用快取時間戳的方式來優化是在Cobar這個專案中:
由於Cobar是一款資料庫中介軟體,它的QPS可能會非常高,所以才有了這個優化,我們瞅一眼他的實現:
- 起一個單獨的執行緒每隔20ms獲取一次時間戳並快取起來
- 使用時間戳時直接取快取
/**
* 弱精度的計時器,考慮效能不使用同步策略。
*
* @author xianmao.hexm 2011-1-18 下午06:10:55
*/
public class TimeUtil {
private static long CURRENT_TIME = System.currentTimeMillis();
public static final long currentTimeMillis() {
return CURRENT_TIME;
}
public static final void update() {
CURRENT_TIME = System.currentTimeMillis();
}
}
timer.schedule(updateTime(), 0L, TIME_UPDATE_PERIOD); // TIME_UPDATE_PERIOD 是 20ms
...
// 系統時間定時更新任務
private TimerTask updateTime() {
return new TimerTask() {
@Override
public void run() {
TimeUtil.update();
}
};
}
Cobar之所以這麼幹,一是因為往往他的QPS非常高,這樣可以減少獲取時間戳的CPU消耗或者耗時;其次是這個時間戳在Cobar內部只做統計使用,就算不準確也並無大礙,從實現上看也確實是弱精度
。
後來我也在其他的程式碼中看到了類似的實現,比如Sentinel(不是Redis的Sentinel,而是阿里開源的限流熔斷利器Sentinel)。
Sentinel作為一款限流熔斷的工具,自然是自身的開銷越小越好,於是同樣都是出自阿里的Sentinel也用了和Cobar類似的實現:快取時間戳
。
原因也很簡單,儘可能減少對系統資源的消耗,獲取時間戳的效能要更優秀,但又不能和Cobar那樣搞個弱精度的時間戳,因為Sentinel獲取到的時間戳很可能就決定了一次請求是否被限流、熔斷。
所以解決辦法也很簡單,直接將快取時間戳的間隔改成1毫秒
去年我還寫過一篇文章《低開銷獲取時間戳》,裡面有Sentinel這段程式碼:
甚至後來的Sentinel-Go也採取了一模一樣的邏輯:
以前沒有多想,認為這樣並沒有什麼不妥。
直到前兩天晚上,沒事在Sentinel-Go社群中瞎逛,看到了一個issue,大受震撼:
提出這位issue的大佬在第一段就給出了非常有見解的觀點:
說的比較委婉,什麼叫「負向收益」?
我又搜尋了一下,找到了這個issue:
TimeUtil吃掉了50%的CPU,這就是「負向收益」,還是比較震驚的!
看到這個issue,我簡單地想了下:
- 耗時:獲取時間戳在一般情況下耗時幾乎都不會影響到系統,尤其是我們常寫的業務系統
- CPU:假設每毫秒快取一次時間戳,拋開其他開銷不說,每秒就有1000次獲取時間戳的呼叫,如果每次請求中只有1次獲取時間戳的操作,那麼至少得有1000QPS的請求,才能填平快取時間戳的開銷,況且還有其他開銷
但這只是我們的想當然,如果有資料支撐就又說服力了。為此前面提出「負向收益」的大佬做了一系列分析和測試,我們白嫖一下他的成果:
看完後我跪在原地,久久不能起身。
課代表來做個總結:
- 快取時間戳開銷最大的地方是sleep和獲取時間戳
- 理論上來說單機QPS需要大於4800才會有正向收益,真實測試結果也是在4000QPS以內都沒有正向收益
- 如果不要這個快取時間戳,獲取時間戳耗時會增加,但這在可接受範圍內
- 鑑於常規情況下QPS很少會達到4K,所以最後結論是在Sentinel-Go中預設禁用這個特性
這一頓操作下來,連Sentinel社群的大佬也覺得很棒,豎起來大拇指:
然而做了這麼多測試,最後的修改就只是把true改成false:
自適應演算法
本來我以為看到這位大佬的測試已經是非常有收穫了,沒想到接下去的閒逛又讓我發現了一個更了不得的東西。
既然上面分析出來,在QPS比較高的情況下,收益才能抵消被抵消,那麼有沒有可能實現一個自適應的演算法,在QPS較低的時候直接從系統獲取,QPS較高時,從快取獲取。
果不其然,Sentinel(Java版,版本>=1.8.2)已經實現了!
我們捋一下它的實現:
我們首先看最核心的快取時間戳的迴圈(每毫秒執行1次),在這個迴圈中,它將快取時間戳分成了三個狀態:
- RUNNING:執行態,執行快取時間戳策略,並統計寫時間戳的QPS(把對快取時間戳的讀寫QPS分開統計)
- IDLE:空閒態(初始狀態),什麼都不做,只休眠300毫秒
- PREPARE:準備態,快取時間戳,但不統計QPS
這三個狀態怎麼流轉呢?答案在開頭呼叫的check
方法中:
首先check邏輯有個間隔,也就是每隔一段時間(3秒)來做一次狀態轉換;
其次如果當前狀態是空閒態
並且讀QPS大於HITS_UPPER_BOUNDARY
(1200),則切換為準備態
。
如果當前狀態是執行態
且讀QPS小於HITS_LOWER_BOUNDARY
(800),則切換為空閒態
。
發現似乎少了切換到執行態
的分支,看上面的迴圈中,第三個準備態
的分支執行一次就將狀態切換為執行態
了。
這是為啥?其實準備態
只是為了讓程式從空閒態
切換到執行態
時過渡的更平滑,因為空閒態
下快取時間戳不再更新,如果沒有過渡直接切換到執行態
,那可能切換後獲取的時間戳是有誤差的。
文字可能不直觀,我們畫一個狀態流轉圖:
最後這些準備好了,獲取時需要做兩件事:一是統計讀時間戳的QPS,二是獲取時間戳;如果是空閒態
或準備態
則直接獲取系統時間返回,如果是執行態
則從快取中拿時間戳。
當程式比較空閒時,不會快取時間戳,降低CPU的消耗,QPS較高時快取時間戳,也能降低CPU的消耗,並且能降低獲取時間戳的時延,可謂是一舉兩得。
但這中間我有個疑問,這裡QPS的高低邊界不知道是如何得出的,是拍腦袋還是壓測出來的,不過這個數值似乎並不一定絕對準確,可能和機器的配置也有關係,所以我傾向這個值可以配置,而不是在程式碼中寫死,關於這點,這段程式碼的作者也解釋了原因:
最後可能你會問,這QPS咋統計呀?
這可是Sentinel的強項,利用LeapArray
統計,由於這不是本文重點,就不展開了,有興趣可以參考我之前的文章《Sentinel-Go 原始碼系列(三)滑動時間視窗演算法的工程實現》,雖然文章是Go的,但演算法和Java的是一模一樣,甚至實現都是照搬。
有沒有測試資料支撐呢?有另一位大佬在評論區貼出了他的測試資料,我們看一下:
在低負載下,CPU消耗降低的特別明顯,高負載則沒什麼變化,這也符合我們的預期。
看到這裡你是不是覺得該點題了?沒錯,Sentinel-Go還沒實現上述的自適應演算法,這是個絕佳的機會,有技術含量,又有參考(Java版),是不是心動了?
社群中也有該issue:
這個issue在2021年8月有個哥們認領了,但截止目前還沒貢獻程式碼,四捨五入等於他放棄了,所以你懂我意思吧?
最後說一句
如果你覺得文章還可以,麻煩動動小手,點個關注
、在看
、贊
,你的鼓勵是我持續創作的動力!
對了,如果覺得還不過癮,可以再看看這些相關文章:
- 《參與開源專案很難嗎?》
- 《Sentinel-Go 原始碼系列(一)|開篇》
- 《Sentinel-Go 原始碼系列(二)|初始化流程和責任鏈設計模式》
- 《Sentinel-Go 原始碼系列(三)滑動時間視窗演算法的工程實現》
- 《Sentinel在docker中獲取CPU利用率的一個BUG》
- 《低開銷獲取時間戳》
感謝閱讀,我們下期再見~
搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐。