一頓騷操作版本號比較效能提升300%

Gopher指北發表於2022-03-24
來自公眾號:Gopher指北

在一次效能分析中,發現線上服務CompareVersion佔用了較長的CPU時間。如下圖所示。

其中佔用時間最長的為strings.Split函式,這個函式對Gopher來說應該是非常熟悉的。而CompareVersion就是基於strings.Split函式來實現版本比較的,下面看一下CompareVersion的實現。

// 判斷是否全為0
func zeroRune(s []rune) bool {
    for _, r := range s {
        if r != '0' && r != '.' {
            return false
        }
    }
    return true
}
// CompareVersion 比較兩個appversion的大小
// return 0 means ver1 == ver2
// return 1 means ver1 > ver2
// return -1 means ver1 < ver2
func CompareVersion(ver1, ver2 string) int {
    // fast path
    if ver1 == ver2 {
        return 0
    }
    // slow path
    vers1 := strings.Split(ver1, ".")
    vers2 := strings.Split(ver2, ".")
    var (
        v1l, v2l = len(vers1), len(vers2)
        i        = 0
    )
    for ; i < v1l && i < v2l; i++ {
        a, e1 := strconv.Atoi(vers1[i])
        b, e2 := strconv.Atoi(vers2[i])
        res := 0
        // 如果不能轉換為數字,使用go預設的字串比較
        if e1 != nil || e2 != nil {
            res = strings.Compare(vers1[i], vers2[i])
        } else {
            res = a - b
        }
        // 根據比較結果進行返回, 如果res=0,則此部分相等
        if res > 0 {
            return 1
        } else if res < 0 {
            return -1
        }
    }
    // 最後誰仍有剩餘且不為0,則誰大
    if i < v1l {
        for ; i < v1l; i++ {
            if !zeroRune([]rune(vers1[i])) {
                return 1
            }
        }
    } else if i < v2l {
        for ; i < v2l; i++ {
            if !zeroRune([]rune(vers2[i])) {
                return -1
            }
        }
    }
    return 0
}

嘗試優化strings.Split函式

CompareVersion的邏輯清晰且簡單,而根據火焰圖知效能主要消耗在strings.Split函式上,所以老許的第一目標是嘗試優化strings.Split函式。

每當此時老許首先想到的方法就是百度大法和谷歌大法,最後在某篇文章中發現strings.FieldsFunc函式,根據該文章描述,strings.FieldsFunc函式分割字串的速度遠快於strings.Split函式。那麼我們到底能不能使用strings.FieldsFunc函式替換strings.Split函式請看下面測試結果。

func BenchmarkSplit(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        strings.Split("7.0.09.000", ".")
        strings.Split("7.0.09", ".")
        strings.Split("9.01", ".")
    }
}

func BenchmarkFieldsFunc(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        strings.FieldsFunc("7.0.09.000", func(r rune) bool { return r == '.' })
        strings.FieldsFunc("7.0.09", func(r rune) bool { return r == '.' })
        strings.FieldsFunc("9.01", func(r rune) bool { return r == '.' })
    }
}

上述benchmark測試在老許的機器上某次執行結果如下:

cpu: Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
BenchmarkSplit-4                 3718506               303.2 ns/op           144 B/op          3 allocs/op
BenchmarkSplit-4                 4144340               287.6 ns/op           144 B/op          3 allocs/op
BenchmarkSplit-4                 3859644               304.5 ns/op           144 B/op          3 allocs/op
BenchmarkSplit-4                 3729241               287.9 ns/op           144 B/op          3 allocs/op
BenchmarkFieldsFunc-4            3459463               336.5 ns/op           144 B/op          3 allocs/op
BenchmarkFieldsFunc-4            3604345               335.5 ns/op           144 B/op          3 allocs/op
BenchmarkFieldsFunc-4            3411564               313.9 ns/op           144 B/op          3 allocs/op
BenchmarkFieldsFunc-4            3661268               309.6 ns/op           144 B/op          3 allocs/op

根據輸出知,strings.FieldsFunc函式沒有想象中那麼快,甚至還比不過strings.Split函式。既然此路不通,老許只好再另尋他法。

嘗試引入快取

按照最卷的公司來,假如我們每週一個版本,且全年無休則一個公司要釋出1000個版本需19年(1000/(365 / 7))。基於這個內卷的資料,我們如果能夠把這些版本都快取起來,然後再比較大小,其執行速度絕對有一個質的提升。

自實現過期快取

要引入快取的話,老許第一個想到的就是過期快取。同時為了儘可能的輕量所以自己實現一個過期快取無疑是一個不錯的方案。

1、定義一個包含過期時間和資料的結構體

type cacheItem struct {
    data      interface{}
    expiredAt int64
}

// IsExpired 判斷快取內容是否到期
func (c *cacheItem) IsExpired() bool {
    return c.expiredAt > 0 && time.Now().Unix() >= c.expiredAt
}

2、使用sync.Map作為併發安全的快取

var (
    cacheMap sync.Map
)

// Set 增加快取
func Set(key string, val interface{}, expiredAt int64) {
    cv := &cacheItem{val, expiredAt}
    cacheMap.Store(key, cv)
}

// Get 得到快取中的值
func Get(key string) (interface{}, bool) {
    // 不存在快取
    cv, isExists := cacheMap.Load(key)
    if !isExists {
        return nil, false
    }
    // 快取不正確
    citem, ok := cv.(*cacheItem)
    if !ok {
        return nil, false
    }
    // 讀資料時刪除快取
    if citem.IsExpired() {
        cacheMap.Delete(key)
        return nil, false
    }
    // 最後返回結果
    return citem.Data(), true
}

3、定義一個通過.分割可儲存每部分資料的結構體

// 快取一個完整的版本使用切片即可
type cmVal struct {
    iv int
    sv string
    // 能否轉換為整形
    canInt bool
}

4、將app版本轉為切片以方便快取

func strs2cmVs(strs []string) []*cmVal {
    cmvs := make([]*cmVal, 0, len(strs))
    for _, v := range strs {
        it, e := strconv.Atoi(v)
        // 全部資料都儲存
        cmvs = append(cmvs, &cmVal{it, v, e == nil})
    }
    return cmvs
}

5、使用帶快取的方式進行版本大小比較

func CompareVersionWithCache1(ver1, ver2 string) int {
    // fast path
    if ver1 == ver2 {
        return 0
    }
    // slow path
    var (
        cmv1, cmv2             []*cmVal
        cmv1Exists, cmv2Exists bool
        expire                 int64 = 200 * 60
    )
    // read cache 1
    cmv, cmvExists := Get(ver1)
    if cmvExists {
        cmv1, cmv1Exists = cmv.([]*cmVal)
    }
    if !cmv1Exists {
        // set val and cache
        cmv1 = strs2cmVs(strings.Split(ver1, "."))
        Set(ver1, cmv1, time.Now().Unix()+expire)
    }
    // read cache 2
    cmv, cmvExists = Get(ver2)
    if cmvExists {
        cmv2, cmv2Exists = cmv.([]*cmVal)
    }
    if !cmv2Exists {
        // set val and cache
        cmv2 = strs2cmVs(strings.Split(ver2, "."))
        Set(ver2, cmv2, time.Now().Unix()+expire)
    }
    // compare ver str
    var (
        v1l, v2l = len(cmv1), len(cmv2)
        i        = 0
    )
    for ; i < len(cmv1) && i < len(cmv2); i++ {
        res := 0
        // can use int compare
        if cmv1[i].canInt && cmv2[i].canInt {
            res = cmv1[i].iv - cmv2[i].iv
        } else {
            res = strings.Compare(cmv1[i].sv, cmv2[i].sv)
        }
        if res > 0 {
            return 1
        } else if res < 0 {
            return -1
        }
    }
    if i < v1l {
        for ; i < v1l; i++ {
            if cmv1[i].canInt && cmv1[i].iv != 0 {
                return 1
            }
            if !zeroRune([]rune(cmv1[i].sv)) {
                return 1
            }
        }
    } else if i < v2l {
        for ; i < v2l; i++ {
            for ; i < v1l; i++ {
                if cmv2[i].canInt && cmv2[i].iv != 0 {
                    return -1
                }
                if !zeroRune([]rune(cmv2[i].sv)) {
                    return -1
                }
            }
        }
    }
    return 0
}

CompareVersionWithCache1函式比較步驟為:

  • 如果版本字串相等直接返回
  • 分別讀取兩個版本對應的快取資料,如果沒有快取資料資料則生成快取資料並快取
  • 分別對比兩個版本對應的[]*cmVal資料,返回大小

最後進行效能驗證,以下為CompareVersionWithCache1函式和CompareVersion函式的benchmark對比。

cpu: Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
BenchmarkCompareVersion-4                  1642657           767.6 ns/op         304 B/op           6 allocs/op
BenchmarkCompareVersionWithCache1-4        1296520           844.9 ns/op           0 B/op           0 allocs/op

通過上述結果分析知,使用快取後唯一的優化只是減少了微乎其微的記憶體分配。這個結果實在令老許充滿了疑惑,在使用pprof分析後終於發現效能沒有提升的原因。以下為benchmark期間BenchmarkCompareVersionWithCache1函式的火焰圖。

因為考慮到app版本數量較小,所以使用了惰性淘汰的方式淘汰過期快取,在每次讀取資料時判斷快取是否過期。根據火焰圖知效能損耗最大的就是判斷快取是否過期,每次判斷快取是否過期都需要呼叫 time.Now().Unix()得到當前時間戳。也就是因為time.Now()的這個呼叫導致這次優化功虧一簣。

引入LRU快取

考慮到版本數量本身不多,且對於常用的版本可以儘可能永久快取,因此引入LRU快取做進一步效能優化嘗試。

1、引入開源的LRU快取,對應開源庫為: github.com/hashicorp/golang-lru

2、在CompareVersionWithCache1函式的基礎上將讀寫快取替換為引入的LRU快取

最後進行效能驗證,以下為CompareVersionWithCache2函式和CompareVersion函式的benchmark對比。

cpu: Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
BenchmarkCompareVersion-4                  1583202           841.7 ns/op         304 B/op           6 allocs/op
BenchmarkCompareVersionWithCache2-4        1671758           633.9 ns/op          96 B/op           6 allocs/op

哎,這個結果終於有點樣子了,但優化效果並不明顯,還有進一步提升的空間。

自實現LRU快取

選擇LRU快取是有效果的,在這個基礎上老許決定自己實現一個極簡的LRU快取。

1、定義一個快取節點結構體

type lruCacheItem struct {
    // 雙向連結串列
    prev, next *lruCacheItem
    // 快取資料
    data       interface{}
    // 快取資料對應的key
    key        string
}

2、 定義一個操作LRU快取的結構體

type lruc struct {
    // 連結串列頭指標和尾指標
    head, tail *lruCacheItem
    // 一個map儲存各個連結串列的指標,以方便o(1)的複雜度讀取資料
    lruMap     map[string]*lruCacheItem
    rw         sync.RWMutex
    size       int64
}

func NewLRU(size int64) *lruc {
    if size < 0 {
        size = 100
    }
    lru := &lruc{
        head:   new(lruCacheItem),
        tail:   new(lruCacheItem),
        lruMap: make(map[string]*lruCacheItem),
        size:   size,
    }
    lru.head.next = lru.tail
    lru.tail.prev = lru.head
    return lru
}

3、LRU快取的Set方法

func (lru *lruc) Set(key string, v interface{}) {
    // fast path
    if _, exist := lru.lruMap[key]; exist {
        return
    }
    node := &lruCacheItem{
        data: v,
        prev: lru.head,
        next: lru.head.next,
        key:  key,
    }
    // add first
    lru.rw.Lock()
    // double check
    if _, exist := lru.lruMap[key]; !exist {
        lru.lruMap[key] = node
        lru.head.next = node
        node.next.prev = node
    }
    if len(lru.lruMap) > int(lru.size) {
        // delete tail
        prev := lru.tail.prev
        prev.prev.next = lru.tail
        lru.tail.prev = prev.prev
        delete(lru.lruMap, prev.key)
    }
    lru.rw.Unlock()
}

4、LRU快取的Get方法

func (lru *lruc) Get(key string) (interface{}, bool) {
    lru.rw.RLock()
    v, ok := lru.lruMap[key]
    lru.rw.RUnlock()
    if ok {
        // move to head.next
        lru.rw.Lock()
        v.prev.next = v.next
        v.next.prev = v.prev

        v.prev = lru.head
        v.next = lru.head.next
        lru.head.next = v
        lru.rw.Unlock()
        return v.data, true
    }
    return nil, false
}

5、在CompareVersionWithCache1函式的基礎上將讀寫快取替換為自實現的LRU快取

最後進行效能驗證,以下為CompareVersionWithCache3函式和CompareVersion函式的benchmark對比:

cpu: Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
BenchmarkCompareVersion-4                  1575007           763.1 ns/op         304 B/op           6 allocs/op
BenchmarkCompareVersionWithCache3-4        3285632           317.6 ns/op           0 B/op           0 allocs/op

引入自實現的LRU快取後,效能足足提升了一倍,到這裡老許幾乎準備去公司裝逼了,但是心裡總有個聲音在問我有沒有無鎖的方式讀取快取。

減少LRU快取鎖競爭

無鎖的方式確實沒有想到,只想到了兩種減少鎖競爭的方式。

  • 不需要每次讀資料時都將節點移動到連結串列頭,只有當LRU快取數量接近Size上限的時候才將最新讀取的資料移動到連結串列頭
  • 既然是LRU快取,那麼訪問頻率越高,快取節點越靠近連結串列頭,基於這個特性可以考慮在每次訪問的時候加入隨機數以減小鎖的競爭(即訪問頻率越高越有機會通過隨機數控制將快取節點移動到連結串列頭)。

加入隨機數後的實現如下:

func (lru *lruc) Get(key string) (interface{}, bool) {
    lru.rw.RLock()
    v, ok := lru.lruMap[key]
    lru.rw.RUnlock()
    if ok {
        // 這裡隨機寫100
        if rand.Int()%100 == 1 {
            lru.rw.Lock()
            v.prev.next = v.next
            v.next.prev = v.prev

            v.prev = lru.head
            v.next = lru.head.next
            lru.head.next = v
            lru.rw.Unlock()
        }
        return v.data, true
    }
    return nil, false
}

加入隨機數後的CompareVersionWithCache3函式和CompareVersion函式的benchmark對比如下:

cpu: Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
BenchmarkCompareVersion-4                  1617837           761.5 ns/op         304 B/op           6 allocs/op
BenchmarkCompareVersionWithCache3-4        4817722           251.3 ns/op           0 B/op           0 allocs/op

加入隨機數後,CompareVersionWithCache3函式效能再次提升20%左右。優化還沒結束,當快取數量遠不足設定的快取上限時不需要移動到連結串列頭。

func (lru *lruc) Get(key string) (interface{}, bool) {
    lru.rw.RLock()
    v, ok := lru.lruMap[key]
    lru.rw.RUnlock()

    if ok {
        // move to head.next
        if len(lru.lruMap) > int(lru.size)-1 && rand.Int()%100 == 1 {
            lru.rw.Lock()
            v.prev.next = v.next
            v.next.prev = v.prev

            v.prev = lru.head
            v.next = lru.head.next
            lru.head.next = v
            lru.rw.Unlock()
        }
        return v.data, true
    }
    return nil, false
}

引入上述優化後,benchmark對比如下:

cpu: Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
BenchmarkCompareVersion-4                1633576               793.2 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersion-4                1619822               882.7 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersion-4                1639792               737.2 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersion-4                1630004               758.3 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersionWithCache3-4      7538025               155.9 ns/op             0 B/op          0 allocs/op
BenchmarkCompareVersionWithCache3-4      7514742               150.1 ns/op             0 B/op          0 allocs/op
BenchmarkCompareVersionWithCache3-4      8357704               162.9 ns/op             0 B/op          0 allocs/op
BenchmarkCompareVersionWithCache3-4      7748578               148.0 ns/op             0 B/op          0 allocs/op

至此,最終版的版本比較實現在理想情況下(快取空間較足)效能達到原先的4倍。

有的人就是老天爺賞飯吃

本來老許都準備去公司裝逼了,萬萬沒想到同事已經搞了一個更加合理且穩定的版本比較演算法,讓老許自愧不如。

該演算法思路如下:

  • 不使用strings.Split函式將版本以.分割,而是從左到右依次對比每一個字元直至遇到不同的字元,並分別記錄索引i,j
  • 遍歷兩個版本剩餘部分字串,以i、j為始直至遇到第一個.,將這兩部分字串轉為整形進行比較
  • 如果前兩步完成後仍相等,則誰還有剩餘字元則誰大

三種演算法benchmark如下:

cpu: Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
BenchmarkCompareVersion-4                1803190               674.8 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersion-4                1890308               630.9 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersion-4                1855741               631.8 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersion-4                1850410               629.4 ns/op           304 B/op          6 allocs/op
BenchmarkCompareVersionWithCache3-4      8877466               132.2 ns/op             0 B/op          0 allocs/op
BenchmarkCompareVersionWithCache3-4      8489661               132.6 ns/op             0 B/op          0 allocs/op
BenchmarkCompareVersionWithCache3-4      8358210               132.6 ns/op             0 B/op          0 allocs/op
BenchmarkCompareVersionWithCache3-4      8456853               131.9 ns/op             0 B/op          0 allocs/op
BenchmarkCompareVersionNoSplit-4         6309705               178.9 ns/op             8 B/op          2 allocs/op
BenchmarkCompareVersionNoSplit-4         6228823               181.2 ns/op             8 B/op          2 allocs/op
BenchmarkCompareVersionNoSplit-4         6370544               177.8 ns/op             8 B/op          2 allocs/op
BenchmarkCompareVersionNoSplit-4         6351043               180.0 ns/op             8 B/op          2 allocs/op

BenchmarkCompareVersionNoSplit函式不需要引入快取,也不會像BenchmarkCompareVersionWithCache3中的快取數量接近上限後會有一定的效能損失,幾乎是我目前發現的最為理想的版本比較方案。

老許也不說什麼當局者迷,旁觀者清這種酸葡萄一般的話,只得承認有的人就是老天爺賞飯吃。有一說一碰上這種人是我的幸運,我相信他只要有口飯吃,我就能在他屁股後面蹭口湯喝。關於文中最後提到的版本號比較演算法完整實現請至下面的github倉庫檢視:

https://github.com/Isites/are...

最後,衷心希望本文能夠對各位讀者有一定的幫助。

注:

寫本文時, 筆者所用go版本為: go1.16.6

文章中所用完整例子:https://github.com/Isites/go-...

相關文章