發現一個開源專案優化點,點進來就是你的了

捉蟲大師發表於2022-05-25

hello,大家好呀,我是小樓。

最近無聊(摸)閒逛(魚)github時,發現了一個阿里開源專案可以貢獻程式碼的地方。

不是寫單測、改程式碼格式那種,而是比較有挑戰的效能優化,最關鍵的是還不難,仔細看完本文後,有點基礎就能寫出來的那種,話不多說,發車!

image

相信大家在日常寫程式碼獲取時間戳時,會寫出如下程式碼:

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

他得出了一個結論:併發越高,獲取時間戳越慢!

image

image

具體到細節我們也不是很懂,大概原因是由於只有一個全域性時鐘源,高併發或頻繁訪問會造成嚴重的爭用。

快取時間戳

我最早接觸到用快取時間戳的方式來優化是在Cobar這個專案中:

https://github.com/alibaba/cobar

由於Cobar是一款資料庫中介軟體,它的QPS可能會非常高,所以才有了這個優化,我們瞅一眼他的實現:

  • 起一個單獨的執行緒每隔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
...
// 系統時間定時更新任務
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這段程式碼:

image

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

image

以前沒有多想,認為這樣並沒有什麼不妥。

直到前兩天晚上,沒事在Sentinel-Go社群中瞎逛,看到了一個issue,大受震撼:

https://github.com/alibaba/sentinel-golang/issues/441

提出這位issue的大佬在第一段就給出了非常有見解的觀點:

image

說的比較委婉,什麼叫「負向收益」?

我又搜尋了一下,找到了這個issue:

https://github.com/alibaba/Sentinel/issues/1702

image

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

image

看到這個issue,我簡單地想了下:

  • 耗時:獲取時間戳在一般情況下耗時幾乎都不會影響到系統,尤其是我們常寫的業務系統
  • CPU:假設每毫秒快取一次時間戳,拋開其他開銷不說,每秒就有1000次獲取時間戳的呼叫,如果每次請求中只有1次獲取時間戳的操作,那麼至少得有1000QPS的請求,才能填平快取時間戳的開銷,況且還有其他開銷

但這只是我們的想當然,如果有資料支撐就又說服力了。為此前面提出「負向收益」的大佬做了一系列分析和測試,我們白嫖一下他的成果:

image

image

image

image

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

image

課代表來做個總結:

  • 快取時間戳開銷最大的地方是sleep和獲取時間戳
  • 理論上來說單機QPS需要大於4800才會有正向收益,真實測試結果也是在4000QPS以內都沒有正向收益
  • 如果不要這個快取時間戳,獲取時間戳耗時會增加,但這在可接受範圍內
  • 鑑於常規情況下QPS很少會達到4K,所以最後結論是在Sentinel-Go中預設禁用這個特性

這一頓操作下來,連Sentinel社群的大佬也覺得很棒,豎起來大拇指:

image

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

image

自適應演算法

本來我以為看到這位大佬的測試已經是非常有收穫了,沒想到接下去的閒逛又讓我發現了一個更了不得的東西。

既然上面分析出來,在QPS比較高的情況下,收益才能抵消被抵消,那麼有沒有可能實現一個自適應的演算法,在QPS較低的時候直接從系統獲取,QPS較高時,從快取獲取。

果不其然,Sentinel(Java版,版本>=1.8.2)已經實現了!

issue參考:https://github.com/alibaba/Sentinel/pull/1746

我們捋一下它的實現:

image

我們首先看最核心的快取時間戳的迴圈(每毫秒執行1次),在這個迴圈中,它將快取時間戳分成了三個狀態:

  • RUNNING:執行態,執行快取時間戳策略,並統計寫時間戳的QPS(把對快取時間戳的讀寫QPS分開統計)
  • IDLE:空閒態(初始狀態),什麼都不做,只休眠300毫秒
  • PREPARE:準備態,快取時間戳,但不統計QPS

這三個狀態怎麼流轉呢?答案在開頭呼叫的check方法中:

image

首先check邏輯有個間隔,也就是每隔一段時間(3秒)來做一次狀態轉換;

其次如果當前狀態是空閒態並且讀QPS大於HITS_UPPER_BOUNDARY(1200),則切換為準備態

如果當前狀態是執行態且讀QPS小於HITS_LOWER_BOUNDARY(800),則切換為空閒態

發現似乎少了切換到執行態的分支,看上面的迴圈中,第三個準備態的分支執行一次就將狀態切換為執行態了。

這是為啥?其實準備態只是為了讓程式從空閒態切換到執行態時過渡的更平滑,因為空閒態下快取時間戳不再更新,如果沒有過渡直接切換到執行態,那可能切換後獲取的時間戳是有誤差的。

文字可能不直觀,我們畫一個狀態流轉圖:

image

最後這些準備好了,獲取時需要做兩件事:一是統計讀時間戳的QPS,二是獲取時間戳;如果是空閒態準備態則直接獲取系統時間返回,如果是執行態則從快取中拿時間戳。

image

當程式比較空閒時,不會快取時間戳,降低CPU的消耗,QPS較高時快取時間戳,也能降低CPU的消耗,並且能降低獲取時間戳的時延,可謂是一舉兩得。

但這中間我有個疑問,這裡QPS的高低邊界不知道是如何得出的,是拍腦袋還是壓測出來的,不過這個數值似乎並不一定絕對準確,可能和機器的配置也有關係,所以我傾向這個值可以配置,而不是在程式碼中寫死,關於這點,這段程式碼的作者也解釋了原因:

image

最後可能你會問,這QPS咋統計呀?

這可是Sentinel的強項,利用LeapArray統計,由於這不是本文重點,就不展開了,有興趣可以參考我之前的文章《Sentinel-Go 原始碼系列(三)滑動時間視窗演算法的工程實現》,雖然文章是Go的,但演算法和Java的是一模一樣,甚至實現都是照搬。

有沒有測試資料支撐呢?有另一位大佬在評論區貼出了他的測試資料,我們看一下:

image

在低負載下,CPU消耗降低的特別明顯,高負載則沒什麼變化,這也符合我們的預期。

看到這裡你是不是覺得該點題了?沒錯,Sentinel-Go還沒實現上述的自適應演算法,這是個絕佳的機會,有技術含量,又有參考(Java版),是不是心動了?

社群中也有該issue:

https://github.com/alibaba/sentinel-golang/issues/419

image

這個issue在2021年8月有個哥們認領了,但截止目前還沒貢獻程式碼,四捨五入等於他放棄了,所以你懂我意思吧?

最後說一句

如果你覺得文章還可以,麻煩動動小手,點個關注在看,你的鼓勵是我持續創作的動力!

對了,如果覺得還不過癮,可以再看看這些相關文章:

感謝閱讀,我們下期再見~


搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐。

相關文章