天天看點

記一次在 MOSN 對 Dubbo、Dubbo-go-hessian2 的性能優化

背景

螞蟻金服内部對 Service Mesh 的穩定性和性能要求是比較高的,内部 MOSN 廣泛用于生産環境。在雲上和開源社群,RPC 領域 Dubbo 和 Spring Cloud 同樣廣泛用于生産環境,我們在 MOSN 基礎上,支援了 Dubbo 和 Spring Cloud 流量代理。我們發現在支援 Dubbo 協定過程中,經過 Mesh 流量代理後,性能有非常大的性能損耗,在大商戶落地 Mesh 中也對性能有較高要求,是以本文會重點描述在基于 Go 語言庫

dubbo-go-hessian2

、Dubbo 協定中對 

MOSN

 所做的性能優化。

性能優化概述

根據實際業務部署場景,并沒有選用高性能機器,使用普通 Linux 機器,配置和壓測參數如下:

  • Intel(R) Xeon(R) Platinum 8163 CPU @ 2.50GHz 4核16G;
  • pod 配置

    2c、1g

    ,jvm 參數

    -server -Xms1024m -Xmx1024m

  • 網絡延遲 0.23ms, 2台 Linux 機器,分别部署 server+MOSN, 壓測程式  rpc-perfomance

經過3輪性能優化後,使用優化版本 MOSN 将會獲得以下性能收益(架構随機512和1k位元組壓測):

  • 512位元組:MOSN+Dubbo 服務調用 tps 整體提升55-82.8%,rt 降低45%左右,記憶體占用 40M;
  • 1k資料:MOSN+Dubbo 服務調用 tps 整體提升51.1-69.3%,rt 降低41%左右,  記憶體占用 41M;

性能優化工具 pprof

磨刀不誤砍柴工,在性能優化前首先要找到性能卡點,找到性能卡點後,另一個難點就是如何用高效代碼優化替代 slow code。因為螞蟻金服 Service Mesh 是基于 Go 語言實作的,我們首選 Go 自帶的 pprof 性能工具,我們簡要介紹這個工具如何使用。如果我們 Go 庫自帶 http.Server 時并且在 main 頭部導入

import _ "net/http/pprof"

,Go 會幫我們挂載對應的 handler, 詳細可以參考 

godoc

因為 MOSN 預設會在

34902

端口暴露 http 服務,通過以下指令輕松擷取 MOSN 的性能診斷檔案:

go tool pprof -seconds 60 http://benchmark-server-ip:34902/debug/pprof/profile
# 會生成類似以下檔案,該指令采樣cpu 60秒
# pprof.mosn.samples.cpu.001.pb.gz           

然後繼續用 pprof 打開診斷檔案,友善在浏覽器檢視,在圖1-1給出壓測後 profiler 火焰圖:

# http=:8000代表pprof打開8000端口然後用于web浏覽器分析
# mosnd代表mosn的二進制可執行檔案,用于分析代碼符号
# pprof.mosn.samples.cpu.001.pb.gz是cpu診斷檔案
go tool pprof -http=:8000 mosnd pprof.mosn.samples.cpu.001.pb.gz           
記一次在 MOSN 對 Dubbo、Dubbo-go-hessian2 的性能優化

在獲得診斷資料後,可以切到浏覽器 Flame Graph(火焰圖,Go 1.11以上版本自帶),火焰圖的 X 軸坐标代表 CPU 消耗情況,Y 軸代碼方法調用堆棧。在優化開始之前,我們借助 Go 工具 pprof 可以診斷出大緻的性能卡點在以下幾個方面(直接壓 Server 端 MOSN):

  • MOSN 在接收 Dubbo 請求,CPU 卡點在streamConnection.Dispatch;
  • MOSN 在轉發 Dubbo 請求,CPU 卡點在 downStream.Receive;

可以點選火焰圖任意橫條,進去檢視長方塊耗時和堆棧明細(請參考圖1-2和1-3所示):

記一次在 MOSN 對 Dubbo、Dubbo-go-hessian2 的性能優化
記一次在 MOSN 對 Dubbo、Dubbo-go-hessian2 的性能優化

性能優化思路

本文重點記錄優化了哪些 case 才能提升 50%+ 的吞吐量和降低 rt,是以後面直接分析目前優化了哪些 case。在此之前,我們以 Dispatch 為例,看下它為啥那麼吃性能 。在 terminal 中通過以下指令可以檢視代碼行耗費 CPU 資料(代碼有删減):

go tool pprof mosnd pprof.mosn.samples.cpu.001.pb.gz
(pprof) list Dispatch
Total: 1.75mins
     370ms     37.15s (flat, cum) 35.46% of Total
      10ms       10ms    123:func (conn *streamConnection) Dispatch(buffer types.IoBuffer) {
      40ms      630ms    125:    log.DefaultLogger.Tracef("stream connection dispatch data string = %v", buffer.String())
         .          .    126:
         .          .    127:    // get sub protocol codec
         .      250ms    128:    requestList := conn.codec.SplitFrame(buffer.Bytes())
      20ms       20ms    129:    for _, request := range requestList {
      10ms      160ms    134:        headers := make(map[string]string)
         .          .    135:        // support dynamic route
      50ms      920ms    136:        headers[strings.ToLower(protocol.MosnHeaderHostKey)] = conn.connection.RemoteAddr().String()
         .          .    149:
         .          .    150:        // get stream id
      10ms      440ms    151:        streamID := conn.codec.GetStreamID(request)
         .          .    156:        // request route
         .       50ms    157:        requestRouteCodec, ok := conn.codec.(xprotocol.RequestRouting)
         .          .    158:        if ok {
         .     20.11s    159:            routeHeaders := requestRouteCodec.GetMetas(request)
         .          .    165:        }
         .          .    166:
         .          .    167:        // tracing
      10ms       80ms    168:        tracingCodec, ok := conn.codec.(xprotocol.Tracing)
         .          .    169:        var span types.Span
         .          .    170:        if ok {
      10ms      1.91s    171:            serviceName := tracingCodec.GetServiceName(request)
         .      2.17s    172:            methodName := tracingCodec.GetMethodName(request)
         .          .    176:
         .          .    177:            if trace.IsEnabled() {
         .       50ms    179:                tracer := trace.Tracer(protocol.Xprotocol)
         .          .    180:                if tracer != nil {
      20ms      1.66s    181:                    span = tracer.Start(conn.context, headers, time.Now())
         .          .    182:                }
         .          .    183:            }
         .          .    184:        }
         .          .    185:
         .      110ms    186:        reqBuf := networkbuffer.NewIoBufferBytes(request)
         .          .    188:        // append sub protocol header
      10ms      950ms    189:        headers[types.HeaderXprotocolSubProtocol] = string(conn.subProtocol)
      10ms      4.96s    190:        conn.OnReceive(ctx, streamID, protocol.CommonHeader(headers), reqBuf, span, isHearbeat)
      30ms       60ms    191:        buffer.Drain(requestLen)
         .          .    192:    }
         .          .    193:}           

通過上面

list Dispatch

指令,性能卡點主要分布在

159

171

172

181

、和

190

等行,主要卡點在解碼 Dubbo 參數、重複解參數、Tracer、反序列化和 Log 等。

1. 優化 Dubbo 解碼 GetMetas

我們通過解碼 Dubbo 的 body 可以獲得以下資訊,調用的目标接口(interface)和調用方法的服務分組(group)等資訊,但是需要跳過所有業務方法參數,目前使用開源的 hessian-go 庫,解析 string 和 map 性能較差, 提升 hessian 庫解碼性能,會在本文後面講解。

優化思路:

在 MOSN 的 ingress 端(MOSN 直接轉發請求給本地 java server 程序), 我們根據請求的 path 和 version 去窺探使用者使用的 interface 和 group, 建構正确的 dataId 可以進行無腦轉發,無需解碼 body,榨取性能提升。

我們可以在服務注冊時,建構服務釋出的 path、version 和 group 到 interface、group 映射。在 MOSN 轉發 Dubbo 請求時可以通過讀鎖查 cache+ 跳過解碼 body,加速 MOSN 性能。

是以我們建構以下 cache 實作(數組+連結清單資料結構), 可參見

優化代碼 diff

// metadata.go
// DubboPubMetadata dubbo pub cache metadata
var DubboPubMetadata = &Metadata{}

// DubboSubMetadata dubbo sub cache metadata
var DubboSubMetadata = &Metadata{}

// Metadata cache service pub or sub metadata.
// speed up for decode or encode dubbo peformance.
// please do not use outside of the dubbo framwork.
type Metadata struct {
    data map[string]*Node
    mu   sync.RWMutex // protect data internal
}

// Find cached pub or sub metatada.
// caller should be check match is true
func (m *Metadata) Find(path, version string) (node *Node, matched bool) {
    // we found nothing
    if m.data == nil {
        return nil, false
    }

    m.mu.RLocker().Lock()
    // for performance
    // m.mu.RLocker().Unlock() should be called.

    // we check head node first
    head := m.data[path]
    if head == nil || head.count <= 0 {
        m.mu.RLocker().Unlock()
        return nil, false
    }

    node = head.Next
    // just only once, just return
    // for dubbo framwork, that's what we're expected.
    if head.count == 1 {
        m.mu.RLocker().Unlock()
        return node, true
    }

    var count int
    var found *Node

    for ; node != nil; node = node.Next {
        if node.Version == version {
            if found == nil {
                found = node
            }
            count++
        }
    }

    m.mu.RLocker().Unlock()
    return found, count == 1
}

// Register pub or sub metadata
func (m *Metadata) Register(path string, node *Node) {
    m.mu.Lock()
    // for performance
    // m.mu.Unlock() should be called.

    if m.data == nil {
        m.data = make(map[string]*Node, 4)
    }

    // we check head node first
    head := m.data[path]
    if head == nil {
        head = &Node{
            count: 1,
        }
        // update head
        m.data[path] = head
    }

    insert := &Node{
        Service: node.Service,
        Version: node.Version,
        Group:   node.Group,
    }

    next := head.Next
    if next == nil {
        // fist insert, just insert to head
        head.Next = insert
        // record last element
        head.last = insert
        m.mu.Unlock()
        return
    }

    // we check already exist first
    for ; next != nil; next = next.Next {
        // we found it
        if next.Version == node.Version && next.Group == node.Group {
            // release lock and no nothing
            m.mu.Unlock()
            return
        }
    }

    head.count++
    // append node to the end of the list
    head.last.Next = insert
    // update last element
    head.last = insert
    m.mu.Unlock()
}           

通過服務注冊時建構好的 cache,可以在 MOSN 的 stream 做解碼時命中 cache, 無需解碼參數擷取接口和 group 資訊,可參見

:

// decoder.go
// for better performance.
// If the ingress scenario is not using group,
// we can skip parsing attachment to improve performance
if listener == IngressDubbo {
    if node, matched = DubboPubMetadata.Find(path, version); matched {
        meta[ServiceNameHeader] = node.Service
        meta[GroupNameHeader] = node.Group
    }
} else if listener == EgressDubbo {
    // for better performance.
    // If the egress scenario is not using group,
    // we can skip parsing attachment to improve performance
    if node, matched = DubboSubMetadata.Find(path, version); matched {
        meta[ServiceNameHeader] = node.Service
        meta[GroupNameHeader] = node.Group
    }
}           

在 MOSN 的 egress 端(MOSN 直接轉發請求給本地 java client 程序),  我們采用類似的思路, 我們根據請求的 path 和 version 去窺探使用者使用的 interface 和 group, 建構正确的 dataId 可以進行無腦轉發,無需解碼 body,榨取性能提升。

2. 優化 Dubbo 解碼參數

在 Dubbo 解碼參數值的時候 ,MOSN 采用的是 Hessian 的正規表達式查找,非常耗費性能。我們先看下優化前後 benchmark 對比, 性能提升50倍!!!

go test -bench=BenchmarkCountArgCount -run=^$ -benchmem
BenchmarkCountArgCountByRegex-12    200000    6236 ns/op    1472 B/op    24 allocs/op
BenchmarkCountArgCountOptimized-12    10000000    124 ns/op    0 B/op    0 allocs/op           

可以消除正規表達式,采用簡單字元串解析識别參數類型個數,

Dubbo 編碼參數個數字元串實作

并不複雜, 主要給對象加字首、數組加 primitive 類型有單字元代替。采用 Go 可以實作同等解析, 可以參考

func getArgumentCount(desc string) int {
    len := len(desc)
    if len == 0 {
        return 0
    }

    var args, next = 0, false
    for _, ch := range desc {

        // is array ?
        if ch == '[' {
            continue
        }

        // is object ?
        if next && ch != ';' {
            continue
        }

        switch ch {
        case 'V', // void
            'Z', // boolean
            'B', // byte
            'C', // char
            'D', // double
            'F', // float
            'I', // int
            'J', // long
            'S': // short
            args++
        default:
            // we found object
            if ch == 'L' {
                args++
                next = true
                // end of object ?
            } else if ch == ';' {
                next = false
            }
        }

    }
    return args
}           

3. 優化 hessian go 解碼 string 性能

在圖1-2中可以看到 hessian go 在解碼 string 占比 CPU 采樣較高,我們在解碼 Dubbo 請求時,會解析 Dubbo 架構版本、調用 path、接口版本和方法名,這些都是 string 類型,hessian go 解析 string 會影響 RPC 性能。

我們首先跑一下 benchmark 前後解碼 string 性能對比,性能提升 56.11%!!!  對應到 RPC 中有5%左右提升。

BenchmarkDecodeStringOriginal-12     1967202     613 ns/op     272 B/op     6 allocs/op
BenchmarkDecodeStringOptimized-12     4477216     269 ns/op     224 B/op     5 allocs/op           

直接使用 utf-8 byte 解碼,性能最高,之前先解碼 byte 成 rune, 對 rune 解碼成 string 及其耗費性能。增加批量string chunk copy, 降低 read 調用,并且使用 unsafe 轉換 string(避免一些校驗),因為代碼優化 diff 較多,這裡給出

優化代碼 PR

Go SDK 代碼 

runtime/string.go#slicerunetostring

(rune 轉換成 string), 同樣是把 rune 轉成 byte 數組,這裡給了我優化思路啟發。

4. 優化 hessian 庫編解碼對象

雖然消除了 Dubbo 的 body 解碼部分,但是 MOSN 在處理 Dubbo 請求時,必須要借助 hessian 去 decode 請求頭部的架構版本、請求 path 和接口版本值。但是每次在解碼的時候都會建立序列化對象,開銷非常高,因為 hessian 每次在建立 reader 的時候會 allocate 4k 資料并 reset。

10ms       10ms     75:func unSerialize(serializeId int, data []byte, parseCtl unserializeCtl) *dubboAttr {
      10ms      140ms     82:    attr := &dubboAttr{}
      80ms      2.56s     83:    decoder := hessian.NewDecoderWithSkip(data[:])
ROUTINE ======================== bufio.NewReaderSize in /usr/local/go/src/bufio/bufio.go
      50ms      2.44s (flat, cum)  2.33% of Total
         .      220ms     55:    r := new(Reader)
      50ms      2.22s     56:    r.reset(make([]byte, size), rd)
         .          .     57:    return r
         .          .     58:}           

我們可以寫個池化記憶體前後性能對比, 性能提升85.4%!!! ,

benchmark 用例
BenchmarkNewDecoder-12    1487685    803 ns/op    4528 B/op    9 allocs/op
BenchmarkNewDecoderOptimized-12    10564024    117 ns/op    128 B/op    3 allocs/op           

在每次編解碼時,池化 hessian 的 decoder 對象,新增 NewCheapDecoderWithSkip 并支援 reset 複用 decoder。

var decodePool = &sync.Pool{
    New: func() interface{} {
        return hessian.NewCheapDecoderWithSkip([]byte{})
    },
}

// 在解碼時按照如下方法調用
decoder := decodePool.Get().(*hessian.Decoder)
// fill decode data
decoder.Reset(data[:])
hessianPool.Put(decoder)           

5. 優化重複解碼 service 和 methodName 值

xprotocol 在實作 xprotocol.Tracing 擷取服務名稱和方法時,會觸發調用并解析2次,調用開銷比較大。

10ms      1.91s    171:            serviceName := tracingCodec.GetServiceName(request)
         .      2.17s    172:            methodName := tracingCodec.GetMethodName(request)           

因為在 GetMetas 裡面已經解析過一次了,可以把解析過的 headers 傳進去,如果 headers 有了就不用再去解析了,并且重構接口名稱為一個,傳回值為二進制組,消除一次調用。

6. 優化 streamId 類型轉換

在 Go 中将 byte 數組和 streamId 進行互轉的時候,比較費性能。

生産代碼中, 盡量不要使用 fmt.Sprintf 和 fmt.Printf 去做類型轉換和列印資訊。可以使用 strconv 去轉換。

.      430ms    147: reqIDStr := fmt.Sprintf("%d", reqID)
60ms      4.10s    168: fmt.Printf("src=%s, len=%d, reqid:%v\n", streamID, reqIDStrLen, reqIDStr)           

7. 優化昂貴的系統調用

MOSN 在解碼 Dubbo 的請求時,會在 header 中塞一份遠端 host 的位址,并且在 for 循環中擷取 remoteIp,系統調用開銷比較高。

50ms      920ms    136:        headers[strings.ToLower(protocol.MosnHeaderHostKey)] = conn.connection.RemoteAddr().String()           

在擷取遠端位址時,盡可能在 streamConnection 中 cache 遠端 ip 值,不要每次都去調用 RemoteAddr。

8. 優化 slice 和 map 觸發擴容和 rehash

在 MOSN 處理 Dubbo 請求時,會根據接口、版本和分組去建構 dataId,然後比對 cluster, 會建立預設 slice 和 map 對象,經過性能診斷,導緻不斷 allocate slice 和 grow map 容量比較費性能。

使用 slice 和 map 時,盡可能預估容量大小,使用 make(type, capacity) 去指定初始大小。

9. 優化 Trace 日志級别輸出

MOSN 中不少代碼在處理邏輯時,會打很多 Trace 級别的日志,并且會傳遞不少參數值。

調用 Trace 輸出前,盡量判斷一下日志級别,如果有多個 Trace 調用,盡可能把所有字元串寫到 buf 中,然後把 buf 内容寫到日志中,并且盡可能少的調用 Trace 日志方法。

10. 優化 Tracer、Log 和 Metrics

在大促期間,對機器的性能要求較高,經過性能診斷,Tracer、MOSN Log 和 Cloud Metrics 寫日志(IO 操作)非常耗費性能。

通過配置中心下發配置或者增加大促開關,允許 API 調用這些 Feature 的開關。

/api/v1/downgrade/on
/api/v1/downgrade/off           

11. 優化 route header 解析

MOSN 中在做路由前,需要做大量的 header 的 map 通路,比如 ldc、antvip 等邏輯判斷,商業版或者開源 MOSN 不需要這些邏輯,這些也會占用一些開銷。

如果是雲上邏輯,内部 MOSN 的邏輯都不走。

12.  優化 featuregate 調用

在 MOSN 中處理請求時,為了區分内部和商業版路由邏輯,會通過 featuregate 判斷邏輯走哪部分。通過 featuregate 調用開銷較大,需要頻繁的做類型轉換和多層 map 去擷取。

通過一個 bool 變量記錄 featuregate 對應開關,如果沒有初始化過,就主動調用一下 featuregate。

未來性能優化思考

經過幾輪性能優化 ,目前看火焰圖,卡點都在 connection 的 read 和 write,可以優化的空間比較小了。但是可能從以下場景中獲得收益:

  • 減少 connection 的 read 和 write 次數(syscall);
  • 優化 IO 線程模型,減少攜程和上下文切換等;

作為結束,給出了最終優化後的火焰圖 ,大部分卡點都在系統調用和網絡讀寫,  請參考圖1-4。

記一次在 MOSN 對 Dubbo、Dubbo-go-hessian2 的性能優化

關于作者

詣極,開源 Apache Dubbo PMC。目前就職于螞蟻金服中間件團隊,主攻 RPC 和 Service Mesh 方向。《深入了解 Apache Dubbo 與實戰》圖書作者。github:

https://github.com/zonghaishang

其他

pprof 工具異常強大,可以診斷 CPU、Memory、Go 協程、Tracer 和死鎖等,該工具可以參考 

,性能優化參考: