源碼閱讀:VictoriaMetrics 中的 golang 代碼優化方法

VictoriaMetrics 監控組件(以下簡稱 VM)號稱比 Prometheus 快了至少 3 倍,內存佔用比 Prometheus 小了 7 倍。

爲什麼能快這麼多呢?下面是閱讀 vm-storage 源碼後的心得:

1.CPU 和併發

基於可用的 CPU 核數來規劃併發

see:victoria-metrics-1.72.0/blob/master/VictoriaMetrics-1.72.0-cluster/lib/cgroup/cpu.go

// AvailableCPUs returns the number of available CPU cores for the app.
func AvailableCPUs() int {
return runtime.GOMAXPROCS(-1)
}

首先通過 AvailableCPUs() 函數來獲取容器環境可用的 CPU 核數,然後在很多場景中都以可用的 CPU 核數來設計協程的數量。

協程的個數不是越多越好,對於計算密級的協程而言,少於可用的核數則浪費了資源;多餘可用的核數,多出核數的協程必然無法同時被調度到,反而會增加了 golang 調度器的壓力。進一步,協程過多會導致大量 CPU 白白浪費在協程調度上。因此,協程數與可用核數相關是很好的策略。(IO 協程又另當別論)

區分 IO 協程和計算協程

see:  VictoriaMetrics-1.72.0-cluster/app/vmstorage/transport/server.go#L261

IO 協程執行收包、解包的任務,然後就把請求數據扔到隊列,不再做複雜的其他計算。

計算協程從 channel 中獲取任務來執行計算。

計算協程數量與 CPU 核數相當,且提供了協程優先級的處理策略

計算協程分爲兩種,寫入協程和查詢協程。寫入協程與 CPU 核數相當,查詢協程是 CPU 核數的兩倍。查詢協程更多的原因是查詢期間可能引發 IO,所以需要併發數多於核數。

在 vm-storage 的場景裏,寫入的優先級高於查詢。

在寫入協程中,設置了一個長度爲 CPU 核數的隊列 (channel),每次準備寫入前先入隊,如果隊滿就等待。這樣就嚴格限制了寫入協程的併發。同時,隊列如果滿,證明高優先級的寫入協程沒有被調度。

vm-storage 提供了機制讓查詢協程主動讓出,在寫入隊列滿的時候通知查詢協程,避免查詢太多影響寫入。

具體的細節請看我的這篇分析文章:《VictoriaMetrics 中協程優先級的處理方式》

特定的成員有特定的鎖

// Table represents mergeset table. // 索引部分的table對象
type Table struct {

partsLock sync.Mutex  //專門針對parts數組的鎖
parts     []*partWrapper  // table對象所屬的所有 part 的數組。使用引用計數
}

例如如上的代碼,parts 數組可能存在併發的問題,專門對這個成員設置了鎖。

這樣的話,就不必用一個很大的鎖來引發劇烈的競爭。代碼中大量此類的優化技巧。

數據分桶,減少鎖競爭

see: VictoriaMetrics-1.72.0-cluster/lib/mergeset/table.go#L128

type rawItemsShards struct {
shardIdx uint32  // 通過原子加來確定分桶,保障各個核操作不同的桶,減少競爭

// shards reduce lock contention when adding rows on multi-CPU systems.
shards []rawItemsShard  // 數組長度與CPU核數一致,通過分桶來減少桶的競爭
}

func (riss *rawItemsShards) addItems(tb *Table, items [][]byte) error {  // items中包含了多個類型的索引數據
n := atomic.AddUint32(&riss.shardIdx, 1)  // 通過原子加來分散到不同的對象,避免鎖的衝突
shards := riss.shards
idx := n % uint32(len(shards))
shard := &shards[idx]
return shard.addItems(tb, items)  //確定分桶後,再轉到具體的分桶對象上處理
}

對於頻繁寫入的大量同類對象,VM 採用了分桶的策略。通過原子加來選定一個不同的分桶,以此來減少競爭。

拷貝出來再處理,減少加鎖的時間

// getParts appends parts snapshot to dst and returns it.
//
// The appended parts must be released with putParts.
func (tb *Table) getParts(dst []*partWrapper) []*partWrapper {  // 複製table 對象的所有 part
tb.partsLock.Lock()
for _, pw := range tb.parts {
pw.incRef()  // 通過引用計數的方法來複制
}
dst = append(dst, tb.parts...)
tb.partsLock.Unlock()

return dst
}

如果對整個數組全部處理完需要很長的時間,還不如把數據拷貝出來,減少加鎖的時間。

引用計數機制,解決併發中可能帶來的對象新增和刪除問題

func (pw *partWrapper) incRef() {
atomic.AddUint64(&pw.refCount, 1)
}

func (pw *partWrapper) decRef() {
n := atomic.AddUint64(&pw.refCount, ^uint64(0))
if int64(n) < 0 {
logger.Panicf("BUG: pw.refCount must be bigger than 0; got %d", int64(n))
}
if n > 0 {
return
}

if pw.mp != nil {
putInmemoryPart(pw.mp)
pw.mp = nil
}
pw.p.MustClose()
pw.p = nil
}

通過一個整形值來記錄對象的引用情況。

某個協程需要這個對象的時候,調用 incRef() 來增加引用計數;使用完成後,調用 decRef() 來減少計數。

當記錄爲 0 時,把對象放入 sync.Pool 對象池。

使用 sync.Value 來處理併發期間可能切換的成員

// Storage represents TSDB storage.
type Storage struct {


idbCurr atomic.Value  // indexdb對象,當前的時間片
}

func (s *Storage) idb() *indexDB {
return s.idbCurr.Load().(*indexDB)
}

存儲中的索引對象被引用得非常頻繁,且存在索引切換的可能。

在這種場景下,使用 sync.Value 來解決併發環境下的對象引用。

2. 內存、對象池、棧逃逸、GC

相比 C/C++ 這樣的語言,golang 在性能方面的最大劣勢是 GC。因此,儘可能的減少堆上的對象,可以減少 GC 的壓力,並減少 GC 運行引起的 STW 的延遲。VM 在這個方面的優化真是做得極其深入。

基於可用的內存來限定對象的總量

see: /VictoriaMetrics-1.72.0-cluster/lib/cgroup/mem.go

通過 GetMemoryLimit() 函數來獲取容器環境的最大可用內存,同時每個 VM 的組件都可以配置-memory.allowedBytes-memory.allowedPercent來限制進程的最大內存。

在內存資源限制的基礎上,各種 cache 和業務處理對象就按照比例進行分配,確保進程絕不會發生 OOM 而崩潰。

相比之下,prometheus 和 thanos 在請求量大的情況下極易發生 OOM 崩潰。

區分 fast path 和 slow path

猜測 VM 的團隊是反反覆覆做了很多的 profile 分析,代碼執行路徑中最常見的路徑,都加上了 fast path 的註釋,其他也標上了 slow path。

fast path 代表了絕大多數 time series 的處理路徑,對內存的優化主要集中在 fast path 上。

fast path 上一個棧逃逸都沒有

也就是說,fast path 上沒有任何一次堆內存分配!!!

從而絕大多數情況下,不會頻繁的新增對象,不會給 GC 帶來大的壓力,更沒有長時間的 STW...

由此可以,團隊在很長的時間裏,對棧逃逸、內存 profile 等繁瑣無聊的做了無數遍,最終優化到一次堆內存分配都沒有的程度!!!

這個手段不值得驚奇,這個效果和這個專業態度實在令人驚奇!

下面這個函數的實現,可見一斑:VictoriaMetrics-1.72.0-cluster/lib/mergeset/encoding.go#L30

// Bytes returns bytes representation of it obtained from data.
//
// The returned bytes representation belongs to data.
func (it Item) Bytes(data []byte) []byte {  // 參數 data 其實沒有必要。但是如果由外部傳入,就不必再分配,對GC的優化達到了極致!牛逼
sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
sh.Cap = int(it.End - it.Start)
sh.Len = int(it.End - it.Start)
sh.Data += uintptr(it.Start)
return data
}

mmap + 緊湊分配——fastcache 中解決海量小對象的分配

關於 fastcache 組件,請看我的這篇分析:《介紹一個 golang 庫:fastcache》

  1. 使用 mmap 系統調用來分配內存,這樣內存就繞過了 GC

  2. 自己來記錄對象在一個大數組中的起始位置,緊湊的存放。這樣就又節約內存又不用考慮大量小對象的產生。

類的部分成員變量其實是臨時變量——小型對象分配的優化

VM 中有大量類似下方的寫法:

type inmemoryPart struct {
ph partHeader
sb storageBlock
bh blockHeader
mr metaindexRow
//下方四個成員變量其實都是臨時變量
unpackedIndexBlockBuf []byte  // 保存上方的block header序列化後的數據
packedIndexBlockBuf   []byte  // 把 unpackedIndexBlockBuf 做 ZSTD壓縮。

unpackedMetaindexBuf []byte  // 把metaindexRow序列化後,存在這裏
packedMetaindexBuf   []byte  // 把 unpackedMetaindexBuf 進行ZSTD壓縮後,存在這裏

metaindexData bytesutil.ByteBuffer
indexData     bytesutil.ByteBuffer
itemsData     bytesutil.ByteBuffer
lensData      bytesutil.ByteBuffer
}

當存在一些需要在函數間傳遞的臨時變量時,VM 把這些臨時變量作爲對象的成員變量來處理。這樣的話,在對象的生命期內,臨時變量只會被分配一次,從而小對象就不會頻繁的分配了。

sync.Pool 的對象池的使用——中型對象分配的優化

中型對象的分配和釋放相對不是很頻繁,因此使用 sync.Pool 來作爲對象池,就可以重用這些對象。中型對象提供了 reset() 方法,把緩衝區的開始位置置 0,而不是解除引用。中型對象相關的所有成員都只會分配一次,然後不再釋放。

如果擔心某些中型對象太耗內存,VM 中還使用了 channel 來保存對象,限制了總的對象個數。這裏同樣也是大型對象的處理策略。

var mpPool = make(chan *inmemoryPart, cgroup.AvailableCPUs())

func getInmemoryPart() *inmemoryPart {
	select {
	case mp := <-mpPool:
		return mp
	default:
		return &inmemoryPart{}
	}
}

func putInmemoryPart(mp *inmemoryPart) {
	mp.Reset()
	select {
	case mpPool <- mp:
	default:
		// Drop mp in order to reduce memory usage.
	}
}

數組的重複使用

VM 代碼中的幾乎所有數組都只分配不釋放,對象使用完成後放回 sync.Pool,以備下次重複使用。

func (o *Obj)foo(){
    o.buf = o.buf[:0]  //把臨時數組作爲對象的成員。使用前清空
    o.buf = o.bar(o.buf)  //函數調用中,通常把目的數組傳入進去
}

3. 通訊協議

連 gogoproto 都沒用,自己用 TLV 的方式來序列化數據

因爲數據都是 time series 的 label name + label value + timestamp + value,因此 vm 中定義了自己的序列化格式,簡單的以 TLV 的方式來組合。在非常具體的業務場景下,這必然是最快的方法。

極其簡單的單工的 TCP 通訊協議

與 prometheus 不同,vm-insert 與 vm-storage 之間的通訊協議沒有選擇 HTTP 協議。

常常有很多的專家冒出來說 HTTP 協議不慢,雲時代用 HTTP 又標準又可讀…… 我不以爲然,二進制協議和文本協議的性能差異是巨大的,在海量高性能低延遲的場景,二進制協議是必然的選擇。

與 thanos 體系不同,vm 也沒有使用 RPC 協議。把多條 time series 數據轉發到 vm-storage 存儲節點這個場景是極其簡單的。簡單的場景就用簡單的方法來做,非得套個標準和方便擴展的理由太牽強。

因此,VM 選擇了極簡的策略,在通訊中使用了文本信息來完成握手,然後用固定 4 字節表示長度 + BODY 的方式來傳輸。並且,傳輸協議是一請求一應答的單工協議。所以解析協議的代碼非常簡單且高效。

使用 ZSTD 壓縮算法來壓縮傳輸內容

通常來說,不管是網絡 IO 還是磁盤 IO,相比 CPU 和內存來說都要慢很多。因此,減少 IO 取得的性能優化提升比優化 CPU 和內存還要來得更簡單。對傳輸內容壓縮肯定是必由之路,且對於 time series 數據傳輸這樣的場景來說,大多數數據都是文本,獲得的壓縮比更優。

zstd 是 facebook 開源的一個 C 語言的壓縮庫。從官方提供的壓測數據看,它的壓縮速度與衆所周知的以快著稱的 snappy 的壓縮速度幾乎持平,但是壓縮率上比老牌的 gzip 還要高。

關於 ZSTD 的介紹,請看我的這篇帖子:《介紹一個 golang 庫:zstd》

合併發送,單一連接

當 vm-insert 需要向 vm-storage 發送數據時,先追加到一個 buffer 中;達到一定時間或者 buffer 滿時,纔會觸發發送邏輯。首先,對 time series 數據傳輸這種場合,秒級延遲就夠了,對延遲不敏感;其次,合併了較多的數據後再壓縮,減小了網絡 IO 的壓力。

與直覺相違背的是,vm-insert 與 vm-storage 之間只有 1 個連接,沒有使用連接池。更多的連接需要更多的 IO 協程來管理併發,且 vm-insert 與 vm-storage 一般都部署在同一 IDC,一個連接足夠了。非常的剋制!

缺點

當然也是有缺點的:

4. 其他

幾乎沒有日誌

程序日誌應該只有一個用途:記錄關鍵事件。

而由用戶請求引起的事件的記錄,我認爲應該歸類到結構化數據上報的範疇,通過把數據導入另一套在線 OLAP 系統來解決。

如果程序日誌太多,通常的原因是:

VM 的代碼中幾乎沒有日誌,給我們豎立了一個很好的典範。

沒有 panic 和 recovery

也意味着,沒有什麼行爲是超出開發者預期的。

監控數據上報,沒有使用經典的 metric 上報 API

VM 自己就是一個監控系統,但是 vm-storage 內部的監控數據上報並未使用 metricAPI.

相反,VM 在每個需要監控上報的位置,用 uint64 類型的成員變量來記錄統計值,使用原子加來累加。

最後由額外的上報協程來讀取這些變量,進行上報。

see: VictoriaMetrics-1.72.0-cluster/lib/storage/storage.go

// UpdateMetrics updates m with metrics from s.
func (s *Storage) UpdateMetrics(m *Metrics) {
	m.RowsAddedTotal = atomic.LoadUint64(&rowsAddedTotal)
	m.DedupsDuringMerge = atomic.LoadUint64(&dedupsDuringMerge)

	m.TooSmallTimestampRows += atomic.LoadUint64(&s.tooSmallTimestampRows)
	m.TooBigTimestampRows += atomic.LoadUint64(&s.tooBigTimestampRows)
}

sync.Once 來做低頻率對象的延遲初始化

see: VictoriaMetrics-1.72.0-cluster/lib/storage/part.go

func getMaxCachedIndexBlocksPerPart() int {
	maxCachedIndexBlocksPerPartOnce.Do(func() {
		n := memory.Allowed() / 1024 / 1024 / 8
		if n < 16 {
			n = 16
		}
		maxCachedIndexBlocksPerPart = n
	})
	return maxCachedIndexBlocksPerPart
}

var (
	maxCachedIndexBlocksPerPart     int
	maxCachedIndexBlocksPerPartOnce sync.Once
)

模仿 roaringBitmap 的數據結構

請移步到我的這篇文章:《vm 中仿照 RoaringBitmap 的實現:uint64set》

位運算技巧

請移步到我的這篇文章:《如何計算一個 uint64 類型的二進制值的尾部有多少個 0》

if 語句上的 string() 轉換會被編譯器優化

請移步到我的這篇文章:《golang 的 if 比較中的 string 轉換會被編譯器優化》

強制約定了 for 循環的寫法

range 在迭代過程中返回的是迭代值的拷貝,如果每次迭代的元素的內存佔用很低,那麼 for 和 range 的性能幾乎是一樣,例如 []int。但是如果迭代的元素內存佔用較高,例如一個包含很多屬性的 struct 結構體,那麼 for 的性能將顯著地高於 range,有時候甚至會有上千倍的性能差異。對於這種場景,建議使用 for,如果使用 range,建議只迭代下標,通過下標訪問迭代值,這種使用方式和 for 就沒有區別了。如果想使用 range 同時迭代下標和值,則需要將切片 / 數組的元素改爲指針,才能不影響性能。

——《Go 語言高性能編程》

VM 團隊應該是在編碼規範上強制約定了 for 循環的寫法。代碼中幾乎所有的數組循環都只有下標部分:

func (riss *rawItemsShards) Len() int {
	n := 0
	for i := range riss.shards {
		n += riss.shards[i].Len()
	}
	return n
}

但是,還是應該區分數組是否是結構體數組:

  1. 如果數組不是結構體數組,上面的寫法沒有任何性能優化的收益;

  2. 如果數組非常大,反而會拖慢——因爲直接訪問數組下標,必然產生數據越界檢查的兩條指令。

idx := 2
a := arrary[idx]
其實被編譯成了這樣的代碼
if idx<0 || idx>=len(array){
    panic("out of range")
} else {
    a = array[idx]
}

通過 fasttime 來減少 time.Now().Unix() 的調用

func init() {
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()
		for tm := range ticker.C {
			t := uint64(tm.Unix())
			atomic.StoreUint64(¤tTimestamp, t)
		}
	}()
}

var currentTimestamp = uint64(time.Now().Unix())

func UnixTimestamp() uint64 {
	return atomic.LoadUint64(¤tTimestamp)
}

我測試過 benchmark,緩存 time.Now().Unix() 後,性能提升 3 倍。

對於大量的需要低精度時間的場合,是個不錯的優化。

缺點:存儲中使用 mmap 映射文件,可能導致協程調度器阻塞

golang 的調度器是很牛的——網絡 IO 和磁盤 IO 的繁忙都不會阻塞住協程調度器,IO 繁忙只會阻塞住具體的發起 io 系統調用的協程。

但是,當 mmap 映射一個大文件時,情況就會不一樣了:

由此,如果缺頁中斷髮生的時候,正好處理 IO 繁忙,則整個物理線程就會被掛起——從而協程調度器被掛起,處於這個調度器上的所有協程被掛起。所幸,一般還有其他核可用。但這是一個可能的風險點。

5. 展望

clickhouse 的文檔裏有這樣一段話:

通常有兩種不同的加速查詢處理的方法:矢量化查詢執行和運行時代碼生成。在後者中,動態地爲每一類查詢生成代碼,消除了間接分派和動態分派。這兩種方法中,並沒有哪一種嚴格地比另一種好。運行時代碼生成可以更好地將多個操作融合在一起,從而充分利用 CPU 執行單元和流水線。矢量化查詢執行不是特別實用,因爲它涉及必須寫到緩存並讀回的臨時向量。如果 L2 緩存容納不下臨時數據,那麼這將成爲一個問題。但矢量化查詢執行更容易利用 CPU 的 SIMD 功能。朋友寫的一篇研究論文表明,將兩種方法結合起來是更好的選擇。ClickHouse 使用了矢量化查詢執行,同時初步提供了有限的運行時動態代碼生成。

從實現帶來來看,VictoriaMetrics 並未採用 SIMD 和 JIT 這兩項技術。可以期待,未來 VM 的性能還能更高!

我已將註釋版的源碼放在了這裏:https://github.com/ahfuzhang/victoria-metrics-1.72.0,對存儲引擎實現有興趣的朋友可以來一起註釋。

敬請期待我後續對於存儲引擎的分析文章。

希望對你有用,have fun :-)

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