记一次在 MOSN 对 Dubbo、Dubbo-go-hessian2 的性能优化

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
全局流量管理 GTM,标准版 1个月
性能测试 PTS,5000VUM额度
简介: 我们发现在支持 Dubbo 协议过程中,经过 Mesh 流量代理后,性能有非常大的性能损耗,在大商户落地 Mesh 中也对性能有较高要求,因此本文会重点描述在基于 Go 语言库 dubbo-go-hessian2、Dubbo 协议中对 MOSN 所做的性能优化。

背景

蚂蚁金服内部对 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

图1-1 压测后 profiler 火焰图

在获得诊断数据后,可以切到浏览器 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所示):

图1-2 长方块耗时

图1-3 堆栈明细

性能优化思路

本文重点记录优化了哪些 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 命令,性能卡点主要分布在 159171172181 、和 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 信息,可参见优化代码 diff :

// 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 可以实现同等解析, 可以参考优化代码 diff

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。

图1-4优化后的火焰图

关于作者

诣极,开源 Apache Dubbo PMC。目前就职于蚂蚁金服中间件团队,主攻 RPC 和 Service Mesh 方向。《深入理解 Apache Dubbo 与实战》图书作者。github: https://github.com/zonghaishang

其他

pprof 工具异常强大,可以诊断 CPU、Memory、Go 协程、Tracer 和死锁等,该工具可以参考 godoc,性能优化参考:

相关文章
|
7月前
|
负载均衡 算法 数据库连接
Go语言性能优化实践:案例分析与解决方案
【2月更文挑战第18天】本文将通过具体的案例分析,探讨Go语言性能优化的实践方法和解决方案。我们将分析几个典型的性能瓶颈问题,并详细介绍如何通过优化代码、调整并发模型、改进内存管理等方式来提升程序的性能。通过本文的学习,读者将能够掌握一些实用的Go语言性能优化技巧,为实际项目开发中的性能优化工作提供指导。
|
2月前
|
Java 编译器 Go
探索Go语言的性能优化技巧
在本文中,我们将深入探讨Go语言的底层机制,以及如何通过代码层面的优化来提升程序性能。我们将讨论内存管理、并发控制以及编译器优化等关键领域,为你提供一系列实用的技巧和最佳实践。
|
2月前
|
并行计算 算法 搜索推荐
探索Go语言的高并发编程与性能优化
【10月更文挑战第10天】探索Go语言的高并发编程与性能优化
|
4月前
|
存储 安全 NoSQL
Go map 读写性能优化 - 分片 map
Go map 读写性能优化 - 分片 map
48 1
|
4月前
|
缓存 监控 中间件
构建高效的Go语言Web服务器:基于Fiber框架的性能优化实践
在追求极致性能的Web开发领域,Go语言(Golang)凭借其高效的并发处理能力、垃圾回收机制及简洁的语法赢得了广泛的青睐。本文不同于传统的性能优化教程,将深入剖析如何在Go语言环境下,利用Fiber这一高性能Web框架,通过精细化配置、并发策略调整及代码层面的微优化,构建出既快速又稳定的Web服务器。通过实际案例与性能测试数据对比,揭示一系列非直觉但极为有效的优化技巧,助力开发者在快节奏的互联网环境中抢占先机。
|
5月前
|
JSON Java Go
Go 语言性能优化技巧
在Go语言中优化性能涉及数字字符串转换(如用`strconv.Itoa()`代替`fmt.Sprintf()`)、避免不必要的字符串到字节切片转换、预分配切片容量、使用`strings.Builder`拼接、有效利用并发(`goroutine`和`sync.WaitGroup`)、减少内存分配、对象重用(`sync.Pool`)、无锁编程、I/O缓冲、正则预编译和选择高效的序列化方法。这些策略能显著提升代码执行效率和系统资源利用率。
101 13
|
7月前
|
监控 算法 测试技术
【Go语言专栏】Go语言的性能优化与内存分析
【4月更文挑战第30天】本文探讨了Go语言的性能优化策略和内存分析方法。性能优化原则包括基准测试、分析瓶颈、避免过早优化和持续监控。优化策略涉及减少内存分配、避免内存逃逸、利用并发、优化算法和数据结构以及减少系统调用。内存分析借助于Go的`pprof`工具、内存分配跟踪和第三方工具,以发现内存泄漏和管理问题。通过这些方法,开发者能提升Go程序效率和资源利用率。
101 0
|
7月前
|
安全 中间件 Go
Go语言Web服务性能优化与安全实践
【2月更文挑战第21天】本文将深入探讨Go语言在Web服务性能优化与安全实践方面的应用。通过介绍性能优化策略、并发编程模型以及安全加固措施,帮助读者理解并提升Go语言Web服务的性能表现与安全防护能力。
|
7月前
|
缓存 监控 Go
Go语言定时器实战:性能优化与实用技巧
Go语言定时器实战:性能优化与实用技巧
154 0
|
Dubbo Cloud Native 应用服务中间件
带你读《Apache Tomcat的云原生演进》——Dubbo Echo System-Dubbo Go Pixiu(1)
带你读《Apache Tomcat的云原生演进》——Dubbo Echo System-Dubbo Go Pixiu(1)
带你读《Apache Tomcat的云原生演进》——Dubbo Echo System-Dubbo Go Pixiu(1)