cover_image

Golang在商业化广告的优化实践

赛博大象技术团队 赛博大象技术团队
2022年04月15日 03:00
一、优化的背景
 ssp系统和adx系统是广告系统中qps最高,io密集度最高的系统,承担着广告大部分收入的同时,对其latency不能响应太慢。ssp负责对接流量方,adx负责对接客户dsp,2个系统都具有高并发、低延迟、高io三个特性,性能对其来说至关重要,否则会影响用户体验和广告预算的消耗。
双十一期间的高流量引起的系统表现来看,adx系统在7核单机qps 2k时cpu达到90%的cpu,latency升高且出现反复宕机的情况(cpu使用过高)。对golang协程轻量级的特性来说,2k的话调优还是有一定的空间,并且为提高机器的经济性。
在经过代码调优和系统参数调优之后,ssp系统单机qps从3.2K升到4.6k,提升43%;adx系统单机2k上升到3.8k,提升85%,系统运行稳定,效果非常明显,省去扩容了。

图片

单机qps:

图片


图片

ssp和adx系统的总流量高峰都达到100K以上。且cpu只使用62%,latency稳定在200ms左右。机器配置: 单pod 7核心,16G内存(ssp 24个pod,adx 27个pod)

二、性能分析工具
观察go进程性能需要先看调用链图,查看图中最红最大的函数。然后根据函数对应的逻辑来进行调试。调用链图推荐使用pprof, 火焰图使用gops,安装方便,使用简单。
 pprof工具   
先介绍pprof工具,pprof是golang自带的分析工具,可以监控cpu、堆、锁、协程的情况。在程序开启pprof:
a.在import中引入   _ "net/http/pprof"b.在任意处开启9099端口  go func() {      log.Info(http.ListenAndServe("localhost:9099", nil))  }()
这样运行程序后,pprof对外暴露了4个路径:
a.暴露的节点如下:go tool pprof  http://127.0.0.1:9099  •  /debug/pprof/profile:访问这个链接会自动进行 CPU profiling,持续 30s,并生成一个文件供下载  •  /debug/pprof/heap:Memory Profiling 的路径,访问这个链接会得到一个内存 Profiling 结果的文件  •  /debug/pprof/block:block Profiling 的路径  •  /debug/pprof/goroutines:运行的 goroutines 列表,以及调用关系b.如果需要分析cpu耗时,则在控制台执行:  go tool pprof  http://127.0.0.1:9099/debug/pprof/profile如果需要分析堆的话,则在控制台执行:  go tool pprof  http://127.0.0.1:9099/debug/pprof/heap即在地址后面拼上需要分析的路径
在应用中开启pprof示例,我们放在init函数中,启动应用时自动加载:
func init() {    rootCmd.AddCommand(serverCmd)    go func() {        log.Info(http.ListenAndServe("localhost:9099", nil))    }()}
执行后,在控制台/命令行中输入命令,用来观察函数的cpu时间片消耗:
go tool pprof  http://127.0.0.1:9099/debug/pprof/profile
执行命令后,程序会对30s秒内的逻辑进行采集分析,等待30s后即可:

图片

出现(pprof),则表示分析完毕,可以进行操作 :

图片

输入  top100 ,查看排名前面100的函数名:

图片

输入tree 查看调用链,可以看到函数之间的串联:

图片

上面的命令不太直观时,就导出svg图进行过查看,导出需要依赖graphviz,用yum安装即可:

yum install graphviz

安装完graphviz后 ,再执行:

go tool pprof  http://127.0.0.1:9099/debug/pprof/profile

待程序分析完后,输入svg:

图片

在当前目录下就会生成svg文件,scp拷贝到本地机器上:

scp profile001.svg admin@10.60.100.139:/Users/admin/Desktop

在本地电脑上直接打开svg文件,即可显示出调用链图:

图片

 火焰图可以使用gops,这里主要用调用链图。


三、优化措施

3.1 golang代码调优

查看调用链图看谁最大最红,百分比占最高就行: 比如看mallocgc,占了13%.85的性能,这里是占进程cpu的总百分比。malloc函数是内存分配地址的系统函数。如果是逻辑问题的话,很容易看出来。

图片

 观察在导出的调用链图,看出其中占比比较大的函数。


a. kafka: saram.withRecover

这个是send函数的主逻辑。 系统是用kafka发送3份重要的数据日志,用来做广告数据分析,此处的send如果不采用批量发送的话,频次会是流量的3-5倍,这个量是极为巨大的。

图片

到在发送消息时调用,未优化前,占比达到21.85%不能接受。根据业务的商量,将kafka client采用批量发送的模式。kafka默认配置是不批量发送,不符合性能要求,将它改为每500ms批量发送1次,降低send函数的调用次数:

config.Producer.Flush.Frequency = 500 * time.Millisecond

在初始化kafka的sender时,添加上Frequency配置,示例:

// 初始化senderfunc initSender(config *sarama.Config, broker string, topic string) (pp *producerProxy, err error) {   config.Producer.Return.Successes = viper.GetBool("kafka.producer.retry.enable")   config.Producer.Retry.Max = viper.GetInt("kafka.producer.retry.max")   config.Producer.Timeout = viper.GetDuration("kafka.producer.timeout") * time.Millisecond   // Flush batches every 500ms   config.Producer.Flush.Frequency = viper.GetDuration("kafka.producer.flush.frequency") * time.Millisecond   return newProducerProxy(config, strings.Split(broker, ","), topic)}

优化后:

图片


b. promethes summary asyncFlush

函数占用48.64% ,系统是采用普罗米修斯做打点监控,统计qps和latency等指标,其中summary是统计latency的百分比函数。

图片

这个asyncFlush函数是summary在本机进行指标合并,500个指标,就需要释放一次。其中的merge函数使用了大量的stream、slice copy 、move操作、以及双层for循环。

func (s *stream) merge(samples Samples) {   // TODO(beorn7): This tries to merge not only individual samples, but   // whole summaries. The paper doesn't mention merging summaries at   // all. Unittests show that the merging is inaccurate. Find out how to   // do merges properly.   var r float64   i := 0   for _, sample := range samples {      for ; i < len(s.l); i++ {         c := s.l[i]         if c.Value > sample.Value {            // Insert at position i.            s.l = append(s.l, Sample{})            copy(s.l[i+1:], s.l[i:])            s.l[i] = Sample{               sample.Value,               sample.Width,               math.Max(sample.Delta, math.Floor(s.ƒ(s, r))-1),               // TODO(beorn7): How to calculate delta correctly?            }            i++            goto inserted         }         r += c.Width      }      s.l = append(s.l, Sample{sample.Value, sample.Width, 0})      i++   inserted:      s.n += sample.Width      r += sample.Width   }   s.compress()}...func (s *stream) compress() {   if len(s.l) < 2 {      return   }   x := s.l[len(s.l)-1]   xi := len(s.l) - 1   r := s.n - 1 - x.Width    for i := len(s.l) - 2; i >= 0; i-- {      c := s.l[i]      if c.Width+x.Width+x.Delta <= s.ƒ(s, r) {         x.Width += c.Width         s.l[xi] = x         // Remove element at i.         copy(s.l[i:], s.l[i+1:])         s.l = s.l[:len(s.l)-1]         xi -= 1      } else {         x = c         xi = i      }      r -= c.Width   }}

问题就在这个merge和compress函数,是极其地消耗性能和内存。网上也有人在说summary这种百分位的计算上是比较耗客户端性能的。且在流量越高的情况下,时间复杂度是非线性增长。

这个没有太好的办法,只好采用试错法定位到具体的指标上,降低其调用次数。直接说结果,1. latency只保留1个p98   2.关闭最经常打点的指标,做了阿波罗开关,需要用来分析的时候开启。

示例:

/** * 监控dsp的响应时长 */DspProcessTime = prometheus.NewSummaryVec(prometheus.SummaryOpts{    Name:       "dsp_latency",    Help:       "dsp latency",    Objectives: map[float64]float64{0.98: 0.001},}, []string{"channel_name"})

adx有10个stage,此处消耗的cpu比较大,做阿波罗开关。

/** * stage耗时:比较耗费性能,由apollo控制开关 */func AddStageLatency(stageName string, latency int64) {   if apollo.GetAdxPrometheusBool("stage_latency_enable", false) {      StageProcessTime.WithLabelValues(stageName).Observe(float64(latency))   }}

处理后见不着普罗米修斯了,cpu使用下降一半,效果比较明显:

图片

c. findrunnable函数

gc和gmp调优: 函数是gmp用来寻找可用的grouptine,这里占15%可以优化下,这里应该都会遇到。

图片

先想到的就是gc和process的不合理使用。

在启动命令前加上 GODEBUG=gctrace=1可以查看系统golang进程的gc日志,可以看到默认是100的情况下1秒内多次gc,并且耗费6%的cpu用于gc操作,不可接受,go默认值的GOGC=100的意思是当内存增长1倍时执行gc,高并发项目很容易达到。设置GOGC=2000,可以降低gc频次和cpu消耗:

图片

GODEBUG=gctrace=1  GOGC=2000 GOTRACEBACK= ./caifeng-adx server --config /home/services/caifeng-adx/bin/prod/application.properties  > /dev/null &

优化后的gc:

gc占cpu降为0%,且频次为2-3秒1次,性能达到最佳。

图片

就是内存从443mb升为1.3G(不会增长),牺牲些空间换取时间。

图片

将执行的process数量置为容器的cpu核数,process数量有限,改为采用配置文件设置process数量,注意此处,golang默认是runtime.GOMAXPROCS(runtime.NumCPU()),因为进程部署到k8s容器内,runtime.NumCPU()获取的是物理机的cpu,物理机的cpu核数是大于pod的cpu核数:

// 设置cpu个线程去运行所有的协程,提高在docker内的性能,配置在文件里,7核心cpuNum := viper.GetInt("instance.cpu")runtime.GOMAXPROCS(cpuNum)

优化后的findrunnable函数降为:

图片

可以了,gmp调度本身也需要耗费cpu。


d.其他组件

     ants协程池在logrr和call dsp:由于协程占用的资源极小,使用协程池来复用协程无明显效果

     fasthttp在call dsp: 无明显效果,还略有下降

     协程池和http都用回原生,原生的效率在高并发情形下最高且经济。

   目前除call dsp、pb proto、json marshal、codis必需外,无较大耗时的函数。adx的qps从2k升3.8k,提升85%,挺明显的。

代码调优汇总:

golang函数

处理方式

kafka: saram.withRecover设置每500ms批量发送
promethes summary asyncFlush只保留p98,且去除大的打点
runtime.findrunnable

设置GOGC=2000

runtime.GOMAXPROCS(cpuNum)

json.marshal

设置为全局变量,设置阿波罗监听改变key值,降低由序列化的损耗


3.2  centos 内核参数调优

centos默认的ip端口数是28231,并不是很大,容易堆满。ssp 当qps达到96k时,出现timewait数堆积,数目过大,会造成go http连接池创建不了连接,go协程出现泄漏。

pod默认端口数:

图片

查看timewait数:

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

timewait出现34922,过大,已超过端口数:

图片

go协程池出现突展,程序宕机:

图片

timewait是tcp/ip的客户端主端关闭连接造成,继而排查程序中使用http的地方,在去除反作弊之后,timewait数迅速下降到588,得出结论由反作弊的context超时关闭了客户端的http连接,出现timewait。

图片

定位问题除主要问题是反作弊超时,反作弊系统需要扩容外,星河ssp的pod的连接没有回收也是主要原因。

治理措施:将pod和物理机都设置开头的优化参数后:

图片

timewait固定在1万以下,不再出现连接打满的情况。目前 在一天星河流量最高峰时 115K不再宕机,协程稳定,效果比较明显。上线后timewait问题解决,然后处理超时的依赖程序。

图片

治理措施,将物理机和pod容器的参数设置为下列,其中包括端口数调大、timewait、keepalive参数调整等,其中最主要的是timewait的回收、重用、超时、最大数量限制:

# ip port调大net.ipv4.ip_local_port_range='1024 65535'# 连接复用net.ipv4.tcp_tw_reuse='1'# 连接回收net.ipv4.tcp_tw_recycle='1'# fin超时控制net.ipv4.tcp_fin_timeout='10'# tcp keep alivenet.ipv4.tcp_keepalive_time='1800'# 探测包的发送次数net.ipv4.tcp_keepalive_probes='3'# keepalive探测包的发送间隔net.ipv4.tcp_keepalive_intvl=15# 网络设备接收数据包的速率比内核处理这些包的速率快时,允许送到队列的数据包的最大数目net.core.netdev_max_backlog='262144'# time wait最大数量net.ipv4.tcp_max_tw_buckets='10000'# sync cookienet.ipv4.tcp_syncookies='1'# 表示SYN队列长度,默认1024,改成8192,可以容纳更多等待连接的网络连接数。net.ipv4.tcp_max_syn_backlog='8192'net.ipv4.tcp_synack_retries='2'net.ipv4.tcp_syn_retries='1'

经过升级之后,服务器\k8s pod在内部分为了高并发服务器、普通服务器,在申请高并发项目时,选择经过优化好参数后的高并发服务器!





文章来自一点资讯商业化产品技术团队

继续滑动看下一个
赛博大象技术团队
向上滑动看下一个