發(fā)現一個開源項目優(yōu)化點,點進來就是你的了
hello,大家好呀,我是小樓。
最近無聊(摸)閑逛(魚)github時,發(fā)現了一個阿里開源項目可以貢獻代碼的地方。
不是寫單測、改代碼格式那種,而是比較有挑戰(zhàn)的性能優(yōu)化,最關鍵的是還不難,仔細看完本文后,有點基礎就能寫出來的那種,話不多說,發(fā)車!

相信大家在日常寫代碼獲取時間戳時,會寫出如下代碼:
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
他得出了一個結論:并發(fā)越高,獲取時間戳越慢!


具體到細節(jié)咱也不是很懂,大概原因是由于只有一個全局時鐘源,高并發(fā)或頻繁訪問會造成嚴重的爭用。
緩存時間戳
我最早接觸到用緩存時間戳的方式來優(yōu)化是在Cobar這個項目中:
https://github.com/alibaba/cobar
由于Cobar是一款數據庫中間件,它的QPS可能會非常高,所以才有了這個優(yōu)化,我們瞅一眼他的實現:
起一個單獨的線程每隔20ms獲取一次時間戳并緩存起來 使用時間戳時直接取緩存
https://github.com/alibaba/cobar/blob/master/server/src/main/server/com/alibaba/cobar/util/TimeUtil.java
/**
?*?弱精度的計時器,考慮性能不使用同步策略。
?*?
?*?@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();
????}
}
https://github.com/alibaba/cobar/blob/master/server/src/main/server/com/alibaba/cobar/CobarServer.java
timer.schedule(updateTime(),?0L,?TIME_UPDATE_PERIOD);?//?TIME_UPDATE_PERIOD?是?20ms
...
//?系統(tǒng)時間定時更新任務
private?TimerTask?updateTime()?{
????return?new?TimerTask()?{
????????@Override
????????public?void?run()?{
????????????TimeUtil.update();
????????}
????};
}
Cobar之所以這么干,一是因為往往他的QPS非常高,這樣可以減少獲取時間戳的CPU消耗或者耗時;其次是這個時間戳在Cobar內部只做統(tǒng)計使用,就算不準確也并無大礙,從實現上看也確實是弱精度。
后來我也在其他的代碼中看到了類似的實現,比如Sentinel(不是Redis的Sentinel,而是阿里開源的限流熔斷利器Sentinel)。
Sentinel作為一款限流熔斷的工具,自然是自身的開銷越小越好,于是同樣都是出自阿里的Sentinel也用了和Cobar類似的實現:緩存時間戳。
原因也很簡單,盡可能減少對系統(tǒng)資源的消耗,獲取時間戳的性能要更優(yōu)秀,但又不能和Cobar那樣搞個弱精度的時間戳,因為Sentinel獲取到的時間戳很可能就決定了一次請求是否被限流、熔斷。
所以解決辦法也很簡單,直接將緩存時間戳的間隔改成1毫秒
去年我還寫過一篇文章《低開銷獲取時間戳》,里面有Sentinel這段代碼:

甚至后來的Sentinel-Go也采取了一模一樣的邏輯:

以前沒有多想,認為這樣并沒有什么不妥。
直到前兩天晚上,沒事在Sentinel-Go社區(qū)中瞎逛,看到了一個issue,大受震撼:
https://github.com/alibaba/sentinel-golang/issues/441
提出這位issue的大佬在第一段就給出了非常有見解的觀點:

說的比較委婉,什么叫「負向收益」?
我又搜索了一下,找到了這個issue:
https://github.com/alibaba/Sentinel/issues/1702

TimeUtil吃掉了50%的CPU,這就是「負向收益」,還是比較震驚的!

看到這個issue,我簡單地想了下:
耗時:獲取時間戳在一般情況下耗時幾乎都不會影響到系統(tǒng),尤其是我們常寫的業(yè)務系統(tǒng) CPU:假設每毫秒緩存一次時間戳,拋開其他開銷不說,每秒就有1000次獲取時間戳的調用,如果每次請求中只有1次獲取時間戳的操作,那么至少得有1000QPS的請求,才能填平緩存時間戳的開銷,況且還有其他開銷
但這只是我們的想當然,如果有數據支撐就又說服力了。為此前面提出「負向收益」的大佬做了一系列分析和測試,我們白嫖一下他的成果:




看完后我跪在原地,久久不能起身。

課代表來做個總結:
緩存時間戳開銷最大的地方是sleep和獲取時間戳 理論上來說單機QPS需要大于4800才會有正向收益,真實測試結果也是在4000QPS以內都沒有正向收益 如果不要這個緩存時間戳,獲取時間戳耗時會增加,但這在可接受范圍內 鑒于常規(guī)情況下QPS很少會達到4K,所以最后結論是在Sentinel-Go中默認禁用這個特性
這一頓操作下來,連Sentinel社區(qū)的大佬也覺得很棒,豎起來大拇指:

然而做了這么多測試,最后的修改就只是把true改成false:

自適應算法
本來我以為看到這位大佬的測試已經是非常有收獲了,沒想到接下去的閑逛又讓我發(fā)現了一個更了不得的東西。
既然上面分析出來,在QPS比較高的情況下,收益才能抵消被抵消,那么有沒有可能實現一個自適應的算法,在QPS較低的時候直接從系統(tǒng)獲取,QPS較高時,從緩存獲取。
果不其然,Sentinel(Java版,版本>=1.8.2)已經實現了!
issue參考:https://github.com/alibaba/Sentinel/pull/1746
我們捋一下它的實現:

我們首先看最核心的緩存時間戳的循環(huán)(每毫秒執(zhí)行1次),在這個循環(huán)中,它將緩存時間戳分成了三個狀態(tài):
RUNNING:運行態(tài),執(zhí)行緩存時間戳策略,并統(tǒng)計寫時間戳的QPS(把對緩存時間戳的讀寫QPS分開統(tǒng)計) IDLE:空閑態(tài)(初始狀態(tài)),什么都不做,只休眠300毫秒 PREPARE:準備態(tài),緩存時間戳,但不統(tǒng)計QPS
這三個狀態(tài)怎么流轉呢?答案在開頭調用的check方法中:

首先check邏輯有個間隔,也就是每隔一段時間(3秒)來做一次狀態(tài)轉換;
其次如果當前狀態(tài)是空閑態(tài)并且讀QPS大于HITS_UPPER_BOUNDARY(1200),則切換為準備態(tài)。
如果當前狀態(tài)是運行態(tài)且讀QPS小于HITS_LOWER_BOUNDARY(800),則切換為空閑態(tài)。
發(fā)現似乎少了切換到運行態(tài)的分支,看上面的循環(huán)中,第三個準備態(tài)的分支運行一次就將狀態(tài)切換為運行態(tài)了。
這是為啥?其實準備態(tài)只是為了讓程序從空閑態(tài)切換到運行態(tài)時過渡的更平滑,因為空閑態(tài)下緩存時間戳不再更新,如果沒有過渡直接切換到運行態(tài),那可能切換后獲取的時間戳是有誤差的。
文字可能不直觀,我們畫一個狀態(tài)流轉圖:

最后這些準備好了,獲取時需要做兩件事:一是統(tǒng)計讀時間戳的QPS,二是獲取時間戳;如果是空閑態(tài)或準備態(tài)則直接獲取系統(tǒng)時間返回,如果是運行態(tài)則從緩存中拿時間戳。

當程序比較空閑時,不會緩存時間戳,降低CPU的消耗,QPS較高時緩存時間戳,也能降低CPU的消耗,并且能降低獲取時間戳的時延,可謂是一舉兩得。
但這中間我有個疑問,這里QPS的高低邊界不知道是如何得出的,是拍腦袋還是壓測出來的,不過這個數值似乎并不一定絕對準確,可能和機器的配置也有關系,所以我傾向這個值可以配置,而不是在代碼中寫死,關于這點,這段代碼的作者也解釋了原因:

最后可能你會問,這QPS咋統(tǒng)計呀?
這可是Sentinel的強項,利用LeapArray統(tǒng)計,由于這不是本文重點,就不展開了,有興趣可以參考我之前的文章《Sentinel-Go 源碼系列(三)滑動時間窗口算法的工程實現》,雖然文章是Go的,但算法和Java的是一模一樣,甚至實現都是照搬。
有沒有測試數據支撐呢?有另一位大佬在評論區(qū)貼出了他的測試數據,我們看一下:

在低負載下,CPU消耗降低的特別明顯,高負載則沒什么變化,這也符合我們的預期。
看到這里你是不是覺得該點題了?沒錯,Sentinel-Go還沒實現上述的自適應算法,這是個絕佳的機會,有技術含量,又有參考(Java版),是不是心動了?
社區(qū)中也有該issue:
https://github.com/alibaba/sentinel-golang/issues/419

這個issue在2021年8月有個哥們認領了,但截止目前還沒貢獻代碼,四舍五入等于他放棄了,所以你懂我意思吧?
最后說一句
如果你覺得文章還可以,麻煩動動小手,點個關注、在看、贊,你的鼓勵是我持續(xù)創(chuàng)作的動力!
對了,如果覺得還不過癮,可以再看看這些相關文章:
《參與開源項目很難嗎?》 《Sentinel-Go 源碼系列(一)|開篇》 《Sentinel-Go 源碼系列(二)|初始化流程和責任鏈設計模式》 《Sentinel-Go 源碼系列(三)滑動時間窗口算法的工程實現》 《Sentinel在docker中獲取CPU利用率的一個BUG》 《低開銷獲取時間戳》
感謝閱讀,我們下期再見~
搜索關注微信公眾號"捉蟲大師",后端技術分享,架構設計、性能優(yōu)化、源碼閱讀、問題排查、踩坑實踐。
