來自公眾號: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-...