记一次提升18倍的性能优化

捉虫大师 等级 70 0 0

背景

最近负责的一个自研的 Dubbo 注册中心经常收到 CPU 使用率的告警,于是进行了一波优化,效果还不错,于是打算分享下思考、优化过程,希望对大家有一些帮助。

自研 Dubbo 注册中心是个什么东西,我画个简图大家稍微感受一下就好,看不懂也没关系,不影响后续的理解。

记一次提升18倍的性能优化

  • Consumer 和 Provider 的服务发现请求(注册、注销、订阅)都发给 Agent,由它全权代理
  • Registry 和 Agent 保持 Grpc 长链接,长链接的目的主要是 Provider 方有变更时,能及时推送给相应的 Consumer。为了保证数据的正确性,做了推拉结合的机制,Agent 会每隔一段时间去 Registry 拉取订阅的服务列表
  • Agent 和业务服务部署在同一台机器上,类似 Service Mesh 的思路,尽量减少对业务的入侵,这样就能快速的迭代了

回到今天的重点,这个注册中心最近 CPU 使用率长期处于中高水位,偶尔有应用发布,推送量大时,CPU 甚至会被打满。

以前没感觉到,是因为接入的应用不多,最近几个月应用越接越多,慢慢就达到了告警阈值。

寻找优化点

由于这项目是 Go 写的(不懂 Go 的朋友也没关系,本文重点在算法的优化,不在工具的使用上), 找到哪里耗 CPU 还是挺简单的:打开 pprof 即可,去线上采集一段时间即可。

具体怎么操作可以参考我之前的这篇文章,今天文章中用到的知识和工具,这篇文章都能找到。

记一次提升18倍的性能优化

CPU profile 截了部分图,其他的不太重要,可以看到消耗 CPU 多的是 AssembleCategoryProviders方法,与其直接关联的是

  • 2个 redis 相关的方法
  • 1个叫assembleUrlWeight的方法

稍微解释下,AssembleCategoryProviders 方法是构造返回 Dubbo provider 的 url,由于会在返回 url 时对其做一些处理(比如调整权重等),会涉及到对这个 Dubbo url 的解析。又由于推拉结合的模式,线上服务使用方越多,这个处理的 QPS 就越大,所以它占用了大部分 CPU 一点也不奇怪。

这两个 redis 操作可能是序列化占用了 CPU,更大头在 assembleUrlWeight,有点琢磨不透。

接下来我们就分析下 assembleUrlWeight 如何优化,因为他占用 CPU 最多,优化效果肯定最好。

下面是 assembleUrlWeight 的伪代码:

func AssembleUrlWeight(rawurl string, lidcWeight int) string {
    u, err := url.Parse(rawurl)
    if err != nil {
        return rawurl
    }

    values, err := url.ParseQuery(u.RawQuery)
    if err != nil {
        return rawurl
    }

    if values.Get("lidc_weight") != "" {
        return rawurl
    }

    endpointWeight := 100
    if values.Get("weight") != "" {
        endpointWeight, err = strconv.Atoi(values.Get("weight"))
        if err != nil {
            endpointWeight = 100
        }
    }

    values.Set("weight", strconv.Itoa(lidcWeight*endpointWeight))

    u.RawQuery = values.Encode()
    return u.String()
}

传参 rawurl 是 Dubbo provider 的url,lidcWeight 是机房权重。根据配置的机房权重,将 url 中的 weight 进行重新计算,实现多机房流量按权重的分配。

这个过程涉及到 url 参数的解析,再进行 weight 的计算,最后再还原为一个 url

Dubbo 的 url 结构和普通 url 结构一致,其特点是参数可能比较多,没有 #后面的片段部分。

记一次提升18倍的性能优化

CPU 主要就消耗在这两次解析和最后的还原中,我们看这两次解析的目的就是为了拿到 url 中的 lidc_weightweight 参数。

url.Parse 和 url.ParseQuery 都是 Go 官方提供的库,各个语言也都有实现,其核心是解析 url 为一个对象,方便地获取 url 的各个部分。

如果了解信息熵这个概念,其实你就大概知道这里面一定是可以优化的。Shannon(香农) 借鉴了热力学的概念,把信息中排除了冗余后的平均信息量称为信息熵

记一次提升18倍的性能优化

url.Parse 和 url.ParseQuery 在这个场景下解析肯定存在冗余,冗余意味着 CPU 在做多余的事情。

因为一个 Dubbo url 参数通常是很多的,我们只需要拿这两个参数,而 url.Parse 解析了所有的参数。

举个例子,给定一个数组,求其中的最大值,如果先对数组进行排序,再取最大值显然是存在冗余操作的。

排序后的数组不仅能取最大值,还能取第二大值、第三大值...最小值,信息存在冗余了,所以先排序肯定不是求最大值的最优解。

优化

优化获取 url 参数性能

第一想法是,不要解析全部 url,只拿相应的参数,这就很像我们写的算法题,比如获取 weight 参数,它只可能是这两种情况(不存在 #,所以简单很多):

  • dubbo://127.0.0.1:20880/org.newboo.basic.MyDemoService?weight=100&...
  • dubbo://127.0.0.1:20880/org.newboo.basic.MyDemoService?xx=yy&weight=100&...

要么是 &weight=,要么是 ?weight=,结束要么是&,要么直接到字符串尾,代码就很好写了,先手写个解析参数的算法:

func GetUrlQueryParam(u string, key string) (string, error) {
    sb := strings.Builder{}
    sb.WriteString(key)
    sb.WriteString("=")
    index := strings.Index(u, sb.String())
    if (index == -1) || (index+len(key)+1 > len(u)) {
        return "", UrlParamNotExist
    }

    var value = strings.Builder{}
    for i := index + len(key) + 1; i < len(u); i++ {
        if i+1 > len(u) {
            break
        }
        if u[i:i+1] == "&" {
            break
        }
        value.WriteString(u[i : i+1])
    }
    return value.String(), nil
}

原先获取参数的方法可以摘出来:

func getParamByUrlParse(ur string, key string) string {
    u, err := url.Parse(ur)
    if err != nil {
        return ""
    }

    values, err := url.ParseQuery(u.RawQuery)
    if err != nil {
        return ""
    }

    return values.Get(key)
}

先对这两个函数进行 benchmark:

func BenchmarkGetQueryParam(b *testing.B) {
    for i := 0; i < b.N; i++ {
        getParamByUrlParse(u, "anyhost")
        getParamByUrlParse(u, "version")
        getParamByUrlParse(u, "not_exist")
    }
}

func BenchmarkGetQueryParamNew(b *testing.B) {
    for i := 0; i < b.N; i++ {
        GetUrlQueryParam(u, "anyhost")
        GetUrlQueryParam(u, "version")
        GetUrlQueryParam(u, "not_exist")
    }
}

Benchmark 结果如下:

BenchmarkGetQueryParam-4          103412              9708 ns/op
BenchmarkGetQueryParam-4          111794              9685 ns/op
BenchmarkGetQueryParam-4          115699              9818 ns/op
BenchmarkGetQueryParamNew-4      2961254               409 ns/op
BenchmarkGetQueryParamNew-4      2944274               406 ns/op
BenchmarkGetQueryParamNew-4      2895690               405 ns/op

可以看到性能大概提升了20多倍

新写的这个方法,有两个小细节,第一是返回值中区分了参数是否存在,这个后面会用到;第二是字符串的操作用到了 strings.Builder,这也是实际测试的结果,使用 +或者 fmt.Springf 性能都没这个好,感兴趣可以测试下看看。

优化 url 写入参数性能

计算出 weight 后再把 weight 写入 url 中,这里直接给出优化后的代码:

func AssembleUrlWeightNew(rawurl string, lidcWeight int) string {
    if lidcWeight == 1 {
        return rawurl
    }

    lidcWeightStr, err1 := GetUrlQueryParam(rawurl, "lidc_weight")
    if err1 == nil && lidcWeightStr != "" {
        return rawurl
    }

    var err error
    endpointWeight := 100
    weightStr, err2 := GetUrlQueryParam(rawurl, "weight")
    if weightStr != "" {
        endpointWeight, err = strconv.Atoi(weightStr)
        if err != nil {
            endpointWeight = 100
        }
    }

    if err2 != nil { // url中不存在weight
        finUrl := strings.Builder{}
        finUrl.WriteString(rawurl)
        if strings.Contains(rawurl, "?") {
            finUrl.WriteString("&weight=")
            finUrl.WriteString(strconv.Itoa(lidcWeight * endpointWeight))
            return finUrl.String()
        } else {
            finUrl.WriteString("?weight=")
            finUrl.WriteString(strconv.Itoa(lidcWeight * endpointWeight))
            return finUrl.String()
        }
    } else { // url中存在weight
        oldWeightStr := strings.Builder{}
        oldWeightStr.WriteString("weight=")
        oldWeightStr.WriteString(weightStr)

        newWeightStr := strings.Builder{}
        newWeightStr.WriteString("weight=")
        newWeightStr.WriteString(strconv.Itoa(lidcWeight * endpointWeight))
        return strings.ReplaceAll(rawurl, oldWeightStr.String(), newWeightStr.String())
    }
}

主要就是分为 url 中是否存在 weight 两种情况来讨论:

  • url 本身不存在 weight 参数,则直接在 url 后拼接一个 weight 参数,当然要注意是否存在 ?
  • url 本身存在 weight 参数,则直接进行字符串替换

细心的你肯定又发现了,当 lidcWeight = 1 时,直接返回,因为 lidcWeight = 1 时,后面的计算其实都不起作用(Dubbo 权重默认为100),索性别操作,省点 CPU。

全部优化完,总体做一下 benchmark:

func BenchmarkAssembleUrlWeight(b *testing.B) {
    for i := 0; i < b.N; i++ {
        for _, ut := range []string{u, u1, u2, u3} {
            AssembleUrlWeight(ut, 60)
        }
    }
}

func BenchmarkAssembleUrlWeightNew(b *testing.B) {
    for i := 0; i < b.N; i++ {
        for _, ut := range []string{u, u1, u2, u3} {
            AssembleUrlWeightNew(ut, 60)
        }
    }
}

结果如下:

BenchmarkAssembleUrlWeight-4               34275             33289 ns/op
BenchmarkAssembleUrlWeight-4               36646             32432 ns/op
BenchmarkAssembleUrlWeight-4               36702             32740 ns/op
BenchmarkAssembleUrlWeightNew-4           573684              1851 ns/op
BenchmarkAssembleUrlWeightNew-4           646952              1832 ns/op
BenchmarkAssembleUrlWeightNew-4           563392              1896 ns/op

大概提升 18 倍性能,而且这可能还是比较差的情况,如果传入 lidcWeight = 1,效果更好。

效果

优化完,对改动方法写了相应的单元测试,确认没问题后,上线进行观察,CPU Idle(空闲率) 提升了10%以上

记一次提升18倍的性能优化

最后

其实本文展示的是一个 Go 程序非常常规的性能优化,也是相对来说比较简单,看完后,大家可能还有疑问:

  • 为什么要在推送和拉取的时候去解析 url 呢?不能事先算好存起来吗?
  • 为什么只优化了这点,其他的点是否也可以优化呢?

针对第一个问题,其实这是个历史问题,当你接手系统时他就是这样,如果程序出问题,你去改整个机制,可能周期比较长,而且容易出问题

记一次提升18倍的性能优化

第二个问题,其实刚也顺带回答了,这样优化,改动最小,收益最大,别的点没这么好改,短期来说,拿收益最重要。当然我们后续也打算对这个系统进行重构,但重构之前,这样优化,足以解决问题。


搜索关注微信公众号"捉虫大师",后端技术分享,架构设计、性能优化、源码阅读、问题排查、踩坑实践。也欢迎加我个人微信MrRoshi,围观朋友圈。

记一次提升18倍的性能优化

收藏
评论区

相关推荐

CPU 缓存一致性协议 MESI
CPU 高速缓存(Cache Memory) ---------------------- ### CPU 为何要有高速缓存 CPU 在摩尔定律的指导下以每 18 个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及 CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而 CPU 的高度运算需要高速的数据。为了解决这个问题,CPU 厂商在 CPU
jdk1.8u131 与jdk1.8u222 cpu获取方式的差异
JDK1.8u222 的cpu获取方式 int OSContainer::active\_processor\_count() { int quota\_count = 0, share\_count = 0; int cpu\_count, limit\_count; int result; cpu\_count = limit\_cou
32位CPU和64位CPU 区别
操作系统只是硬件和应用软件中间的一个平台。 32位操作系统针对的32位的CPU设计。 64位操作系统针对的64位的CPU设计。操作系统只是硬件和应用软件中间的一个平台。 32位操作系统针对的32位的CPU设计。 64位操作系统针对的64位的CPU设计。 我们的CPU从原来的8位,16位,到现在的32位和64位。 cpu处理计算的时候“数据”和
CPU核数怎么计算?
#### 物理cpu数 主板上实际插入的cpu数量,可以数不重复的 physical id 有几个(physical id) # Linux cat /proc/cpuinfo | grep "physical id" | sort | uniq | wc -l #### cpu核数 单块CPU上面能处理数据的芯片组的数量,
CPU调度
**1.CPU调度程序**    每当CPU空闲时,OS必须从就绪队列选择一个进程来执行。进程选择由短期调度程序或CPU调度程序执行。调度程序从内存中选择一个能执行的进程,并为之分配CPU。 **2.抢占**:可以选择              (1)当一个进程从运行状态切换到就绪状态;(eg:当出现中断时)              (2)当一个进
Ubuntu 安装 Caffe
Caffe ===== Caffe 安装(Python2 CPU版本) ----------------------- * 参考博文[https://blog.csdn.net/pangyunsheng/article/details/79418896](https://www.oschina.net/action/GoToLink?url=http
InfluxDB和MySQL的读写对比测试
今天进行了InfluxDB和MySQL的对比测试,这里记录下结果,也方便我以后查阅。 操作系统: CentOS6.5\_x64 InfluxDB版本 : v1.1.0 MySQL版本:v5.1.73 CPU : Intel(R) Core(TM) i5-2320 CPU @ 3.00GHz 内存 :12G 硬盘 :SSD  一、MyS
Intel 80386 CPU
**一、80386 概述** 80386处理器被广泛应用在1980年代中期到1990年代中期的IBM PC相容机中。这些PC机称为「80386电脑」或「386电脑」,有时也简称「80386」或「386」。80386的广泛应用,将PC机从**16位**时代带入了**32位**时代。80386的强大运算能力也使PC机的应用领域得到巨大扩展,商业办公、科学计算、
Kubernetes Pod 驱逐详解
\> 原文链接:[Kubernetes Pod 驱逐详解](https://www.oschina.net/action/GoToLink?url=https%3A%2F%2Fwww.yangcs.net%2Fposts%2Fkubernetes-eviction%2F) 在 Kubernetes 中,Pod 使用的资源最重要的是 CPU、内存和磁盘 IO
Linux下查看CPU型号,内存大小,硬盘空间的命令(详解)
1 查看CPU 1.1 查看CPU个数 \# cat /proc/cpuinfo | grep "physical id" | uniq | wc -l 2 \*\*uniq命令:删除重复行;wc –l命令:统计行数\*\* 1.2 查看CPU核数 \# cat /proc/cpuinfo | grep "cpu cores" | uniq c
Linux性能分析之上下文切换
而在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器 **CPU 寄存器**,是 CPU 内置的容量小、但速度极快的内存。而程序计数器,则是用来存储CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此也被叫做 **C
Linux服务器性能查看分析调优
###### 转自https://www.cnblogs.com/ace-lee/p/6628079.html 一 linux服务器性能查看 ============== 1.1 cpu性能查看 ----------- ### 1、查看物理cpu个数: cat /proc/cpuinfo |grep "physical id"|sort|un
Linux系统性能检测常用命令
##查看CPU数量 * 总核数 = 物理CPU个数 X 每颗物理CPU的核数 * 总逻辑CPU数 = 物理CPU个数 X 每颗物理CPU的核数 X 超线程数 * 查看物理CPU个数: cat /proc/cpuinfo| grep "physical id"| sort| uniq| wc -l
Prometheus Node_exporter 详解
Basic CPU / Mem / Disk Info     [https://www.cnblogs.com/qianyuliang/p/10479515.html](https://www.oschina.net/action/GoToLink?url=https%3A%2F%2Fwww.cnblogs.com%2Fqianyuliang%2Fp%2F
Redis 子进程开销监控和优化方式
Redis子进程负责AOF或者RDB文件的重写,它的运行过程主要涉及CPU、内存、硬盘三部分的消耗 01  CPU CPU开销分析。 子进程负责把进程内的数据分批写入文件,这个过程 属于CPU密集操作,通常子进程对单核CPU利用率接近90% CPU消耗优化。Redis是CPU密集型服务,不要做绑定单核CPU操作。由于子进程非常消耗