网易传媒于2020年将核心业务全部迁入容器,并将在线业务和离线业务混部,CPU利用率提升到了50%以上,取得了较大的收益,但在线业务方面,接入容器后仍存在一些问题:
Go语言于2009年由Google推出,经过了10多年的发展,目前已经有很多互联网厂商都在积极推进Go语言应用,网易传媒于2020年底开始尝试Go语言的探索,用于解决内存资源使用率偏高,编译速度慢等问题。本文将详细描述传媒在Go语言方面的所做的工作。
Go语言介绍
但是瑕不掩瑜,下面就来谈谈Go语言有哪些特性吸引我们去使用。
编译速度快
这种特性的主要原因官方文档里已经提到了:Go编译模型让依赖分析更简单,避免类C语言头文件和库的很多开销。不过这个也引入了一个束缚——包之间无法递归依赖,如果遇到类似的问题只能通过提取公共代码或者在外部初始化包等等方式来解决。
语法简单
像动态语言一样开发
使用过动态语言的应该接触过下面这种Python代码:
defbiu(toy):toy.roll()
o=new_ball()roll(o)
roll函数可以传入任何类型的对象,这种动态语言特征使开发及其灵活方便。但是大家可能都听说过“动态一时爽,重构火葬场”的名言,类似的实现会给其它维护者造成巨大的障碍,如果不是这个原因Python3也就不会加入typehints的特性了。
typeReadIFinterface{Read()}typeWriteIFinterface{Write()}typeReadWriteIFinterface{ReadIFWriteIF}
接下来使用这个interface,注意只要一个对象的类型满足interface里的全部函数,就说明匹配上了。
funcrw(iReadWriteIF){i.Read()i.Write()}typeFilestruct{}func(*File)Read(){}func(*File)Write(){}rw(&File{})
可以看到rw函数根本没有固定传入参数的具体类型,只要对象满足ReadWriteIF即可。
如果希望一个函数能像脚本语言一样接受任何类型的参数,你还可以使用interface{}作为参数类型,比如标准库的fmt.Print系列函数就是这样实现的。
资源消耗少
Go与C/C++消耗的CPU差距不大,但由于Go是垃圾回收型语言,耗费的内存会多一些。由于当前目标是使用Go取代Java,这里就将Go与同为垃圾回收型语言的Java简单比较一下。
Java当年诞生时最大的卖点之一是“一次编写,到处运行”。这个特性在20年前很棒,因为市场上几乎没有虚拟化解决方案。但是到了今天出现了Docker之类一系列跨平台工具,这种卖点可能被看做一种短板,主要原因如下:
为并发IO而生
练习过开发网络库的读者可能都知道Unix的epoll系统调用,如果了解Windows应该听说过IOCP,这两种接口分别对应网络的Reactor和Proactor模式。简单来说前者是同步的事件驱动模型,后者是异步IO。不论你使用任何语言只要涉及到高性能并发IO都逃不过这两种模式开发的折磨——除了Go。
为了展示使用Go开发并发IO有多么简单,我先从大家熟悉的普通程序的线程模型讲起。下图是一个常见的程序线程图,一般来说一个服务进程包含main、日志、网络、其他外部依赖库线程,以及核心的服务处理(计算)线程,其中服务线程可能会按CPU核数配置开启多个。
服务启动后RPC请求到来,此请求的发起端可能是客户端或者另一个服务,那么它在服务线程处理过程中将阻塞并等待回复事件。注意这里的RPC包含广义上的网络协议,比如HTTP、Redis、数据库读写操作都属于RPC。
此时的情况就如下图所示,服务调用端的请求要经过网络往返和服务计算的延迟后才能获得结果,而且服务端很可能还需要继续调用其它服务。
大多数开发者都会想:反正调用一般也就几十毫秒嘛,最多到秒级,我开个线程去同步等待回复就行,这样开发最方便。于是情况就会变成下图这样,每个请求占用一个连接和一个线程。如果网络和计算延迟加大,要保持服务器性能被充分利用,就需要开启更多的连接和线程。
为了偷懒我们倾向于避免使用Reactor和Proactor模式,甚至都懒得去了解它们,就算有人真的希望优化并发IO,类似Jedis这种只支持同步IO的库也能阻止他。
现在有Go能拯救我们了,在Go里没有线程的概念,你只需要知道使用go关键字就能创建一个类似线程的goroutine。Go提供了用同步的代码来写出异步接口的方法,也就是说我们调用IO时直接像上图期望的一样开发就行,Go在后台会调用epoll之类的接口来完成事件或异步处理。这样就避免了把代码写得零碎难懂。下面展示一个简单的RPC客户端例子,RPC调用和后续的计算处理代码可以顺畅地写在一起放入一个goroutine,而这段代码背后就是一个epoll实现的高性能并发IO处理:
funcprocess(client*RPCClient){response:=client.Call()//阻塞compute(response)//CPU密集型业务}
funcmain(){client:=NewRPCClient()fori:=0;i<100;i++{goprocess(client)}select{}//死等}
服务器的代码更简单,不需要再去监听事件,当获取到一个IO对象时,只要使用go就能在后台开启一个新的处理流程。
listener:=Listen("127.0.0.1:8888")for{conn:=listenser.Accept()//阻塞直至连接到来gofunc(){//对每个连接启动一个goroutine做同步处理for{req:=conn.Read()gofunc(){//将耗时处理放入新的goroutine,不阻塞连接的读取res:=compute(req)conn.Write(res)}()}}()}
注意go创建的goroutine相当于将IO读写和事件触发拼接起来的一个容器,消耗的内存非常小,所有goroutine被Go自动调度到有限个数的线程中,运行中切换基本是使用epoll的事件机制,因此这种协程机制可以很迅速启动成千上万个而不太消耗性能。
可运维性好
随着虚拟化技术发展,类似JVM的服务成为了一种累赘;因为磁盘空间大小不再是问题,动态库带来的兼容问题也层出不穷,因此它也在慢慢淡出视野。
Go是一种适应分布式系统和云服务的语言,所以它直接将静态编译作为默认选项,也就是说编译之后只要将可执行文件扔到服务器上或者容器里就能没有任何延迟地运行起来,不需要任何外部依赖库。
此外Go的项目只要在编译时修改参数,就能交叉编译出其他任意支持平台所需的二进制文件。比如我几乎完全在macOS上开发,当需要在linux服务器上测试则使用如下命令编译:
GOOS=linuxGOARCH=amd64gobuild./...
Go支持android、darwin、freebsd、linux、windows等等多种系统,包括386、amd64、arm等平台,绝大部分情况下你可以在自己的笔记本上调试任意系统平台的程序。
与C/C++兼容
由于没有虚拟机机制,Go可以与C语言库比较轻易地互相调用。下面是一个简单的例子,直接在Go中调用C语句:
/*#includevoidmyprint(){printf("hi~");}*/
import"C"C.myprint()
如果使用Go编写一个接口,然后使用gobuild-buildmode=c-shared编译,这样就能得到一个动态库和一个.h头文件,怎么使用就无需再解释了吧。
统一而完备的工具集
Go作为工程语言而设计,它的目标就是统一,即使一个团队有多种风格的开发者,他们的流程和产出最终都需要尽量保持一致,这样协同开发效率才会高。为了保证各方面的统一,Go提供了多种工具,安装以后执行go命令就能直接使用。
2
Ngo框架介绍
背景
在传媒技术团队中推广Go语言,亟需一个Web框架提供给业务开发同事使用,内含业务开发常用库,避免重复造轮子影响效率,并且需要无感知的自动监控数据上报,于是就孕育出Ngo框架。
选型
由于Go的开源Web框架没有类似SpringBoot大而全的,而最大的框架也是很受用户欢迎的框架是Beego,为什么没有直接使用Beego呢?主要有以下几个原因:
目标
Ngo是一个类似JavaSpringBoot的框架,全部使用Go语言开发,主要目标是:
注:哨兵是网易杭研运维部开发的监控系统,提供实时数据分析、丰富的监控指标和直观的报表输出。
主要功能模块
Ngo避免重复造轮子,所有模块都是在多个开源库中对比并挑选其一,然后增加部分必需功能,使其与Java系接口更接近。整个业务服务的架构如下图所示:
HTTPServer
funcmain(){s:=server.Init()s.AddRoute(server.GET,"/hello",func(ctx*gin.Context){ctx.JSON(protocol.JsonBody("hello"))})s.Start()}优雅停机
服务健康检查接口包括4个/health下的对外HTTP接口:
MySQLORM
使用gorm实现MySQLORM的功能,并在之上提供以下功能:
日志
使用logrus实现日志接口,并提供以下功能:
级别包含以下几种:
如果未设置级别,被被默认设置为info。非测试状态不要开启debug,避免日志过多影响性能。
另外在日志输出时可以使用WithField或WithFields来字段的key-value,在创建子日志对象时可以用来清晰地辨认日志的使用范围,但平时尽量不要使用。另外如果要输出error也尽量避免使用字段,直接使用Error()方法输出为字符串是最快的。
Redis
Redis客户端选择go-redis实现。同样只需在配置中提供Redis服务配置,即可在运行中直接使用GetClient获取指定名字的客户端。其支持client、cluster、sentinel三种形式的Redis连接,且都能自动上报哨兵监控数据。
Kafka
Kafka客户端在sarama基础上实现,由于原始接口比较复杂,业务需求一般用不上,Ngo中对其进行了较多的封装。在配置文件中增加kafka段,Ngo即会自动按配置生成生产者和消费者。
生产者只需调用func(p*Producer)Send(messagestring)传入字符串即可上报数据,无需关心结果。此接口是异步操作,会立即返回。如果出错,后台会重试多次,并将最后的结果记录上传到哨兵监控。
Kafka消费者只需这样调用Start注册处理函数即可工作:
consumer.Start(func(message*sarama.ConsumerMessage){//消费代码})HTTPClient
RPC
由于gRPC的使用比较复杂,而且性能与Go标准库的RPC差距不大,因此当前RPC库在Go标准库的基础上开发,并在之上增加连接池、连接复用、错误处理、断开重连、多host支持等功能。在使用上接口与标准库基本一致,因此没有学习成本。
至于使用RPC而不只限制于HTTP的主要原因,一是基于TCP的RPC运行多请求复用连接,而HTTP需要独占连接;二是HTTP在TCP之上实现,header占据了大量overhead,特别在小请求中是不必要的开销。在Ngo的两个库下自带性能测试,运行gotest-bench.就能查看结果,两者都使用20*CPU的并发量,模拟1ms、5ms、50ms的服务器网络和计算延迟,具体结果如下:
配置
配置模块使用viper实现,但用户无需调用配置模块的接口,在每个模块如Redis、Kafka、日志中都会被Ngo自动注入配置,用户只需写好yaml文件即可。
服务需要提供-cconf参数来指定配置文件,启动时,会依次加载以下配置:
配置文件范例如下:
哨兵模块的目的是提供统一且易扩展的接口,适配哨兵数据的收集方式,将各类数据上报到哨兵服务器。它包含两部分:数据收集和数据发送。
数据发送部分在程序启动时会加载当前服务的配置,设定好上报格式,当有收集器上报数据时会调用其接口生成固定的json格式,并使用HTTPClient库上报。
数据收集部分是一个可扩展的库,可以用其创建自定义的收集器,并指定metric和上报间隔,在Redis、Kafka、HTTPClient等库中都已经内置了收集器。一般来说一个收集器的处理行为只需要一种类型的数据来触发,在后台生成多种数据项。比如HTTPClient是每次都传入单次调用的记录,在收集器后台处理时生成对一分钟内所有调用的全汇总、url汇总、host汇总、状态码汇总等类型的数据项。
用户可以用以下实现来创建一个一分钟上报周期的收集器,至于RawData如何去更新ItemData需要用户自己实现。
collector=metrics.NewCollector(&metrics.MetricOptions{Name:metricName,Interval:time.Minute,})collector.Register(itemTypeInvocation,&RawData{},&ItemData1{})collector.Register(itemTypeHostInvocation,&RawData{},&ItemData2{})collector.Start()
后续用户只需调用collector.Push(rawData)就能将数据发送到收集器。数据处理在后台执行,整个收集器处理都是无锁的,不会阻塞用户的调用。
现在Ngo中已内置以下哨兵监控metric:
3
性能压测及线上表现
技术的转型,势必会带来性能表现的差异,这也是我们为什么花费精力来探究的第一因。现在我们将从以下几个维度来对比一下转型为Go之后的所带来的优点和缺点
压测比较
第一轮压测指标:
集群配置:
集群
cpu
内存
节点数量
Jvm配置
Java
2核
500M
xmx300M
xms300M
GO
100M
/
首先我们先看一下整体的不同项目的集群整体表现
Java集群
Go集群
TPS-RT曲线
因为我们加压的过程是直接进入峰值,启动时候的表现,从TPS指标和MaxRT指标,显示Java集群有一个冷启动的过程,而Go集群没有这么一个过程。两者在经历过冷启动之后,性能表现都很稳定。
请求曲线
机器性能指标
cpu-memory
从当前的压测结果和机器性能指标来看,Go集群有更好的并发请求处理能力,请求吞吐量更大,并且在机器资源占用上有更好的优势。使用更少的内存,做了更多的事情。
第二轮压测指标:
集群配置:
700M
xmx400M
xms400M
各项指标曲线和100并发状态相似,除了TPS曲线。Java在200并发下冷起的过程变得更长了。但最终都还是趋于稳定的状态。
机器资源曲线没有太大的变化。
总结:
100并发比较JavaGo配置2C500M2C100MTPS3472.594082.52MRT(ms)28.7424.4499%RT10293MaxRT997227CPU使用率100%100%内存使用率80%50%
200并发比较JavaGo配置2C700M2C100MTPS3410.554061.38MRT(ms)67.5349.1699%RT211103MaxRT2402533CPU使用率100%100%内存使用率80%57%
从两次结果压测结果来看的话,Go在集群中的表现是要优于Java的。Go拥有更好的并发处理能力,使用更少的机器资源。而且不存在冷启动的过程。随着压力的增加,虽然吞吐量没有上去,但是Go集群的RT90和RT99变化不是很大,但是相同分位Java集群的表现则扩大了一倍。而且在100并发情况下,MaxRT指标Java集群和Go集群相差无几,而在200并发情况下,RT99指标Java集群则变成了Go集群的2倍。并且在200并发的情况下,Java集群的TPS有明显的下降。而且TPS的指标的曲线Java的上升曲线过程被拉的更长了。其实换一个角度来看的话,在流量激增的情况下,Java集群的反应反而没有Go稳定。
Go集群线上接口表现
目前我们一共改造了三个接口,业务的复杂度逐渐提升。
hotTag接口表现
机器资源状态
推荐接口表现
结论:
就目前的线上集群的状态来看的话,集群的运行状态比较稳定,而且服务的处理能力是极为高效的。当然了,目前的线上状态Go项目接口单一,整个集群就只有这一个接口提供服务。Java集群因为业务关系,提供的服务接口更多,而且性能表现可能会因为系统IO或者网络带宽问题,导致了性能的看上去没有那么漂亮,更准确的结论会在Java集群中的所有接口全部迁移到Go集群中的时候的数据表现更具有说服力。
4
重构实践与问题
Go协程与Java的线程
talkischeap,showmycode!
Go使用协程
//使用协程来执行util.GoN(func(){topicInfo=GetTopicInfoCachable(tid)},)
Java使用线程
//当然了,我们知道很多种java的线程实现方式,我们就实现其中的一种//定义功能类privateCompletableFuturegetTopicInfoFuture(Stringtid){returnCompletableFuture.supplyAsync(()->{try{returnarticleProviderService.getTopicInfo(tid);}catch(Exceptione){log.error("SubscribeShortnewsServiceImpl.getTopicInfoFuturetid:{}",tid,e);}returnnull;},executor);}//线程使用CompletableFuturetopicInfoFuture=getTopicInfoFuture(tid);TopicInfotopicInfo=null;try{topicInfo=topicInfoFuture.get(2,TimeUnit.SECONDS);}catch(Exceptione){log.error("[SubscribeShortnewsServiceImpl]getSimpleSubscribeTopicHeadfutureerror,tid="+tid,e);}
从上述的代码实现中,我们可以看出来Java代码的书写过程略显冗余,而且被线程执行的过程是需要被实现为特定的类,需要被继承覆盖或者重写的方式来执行线程。想要复用已经存在功能函数会费些周折。但是Go在语法级别支持了协程的实现,可以对已经实现功能做到拿来即可使用,哪怕没有对这个功能做封装。
我个人理解是因为语言的实现理念导致了这种书写方式上的差异。本身Go就是类C语言,它是面向过程的编程方式,而Java又是面向对象编程的优秀代表。因此在不同的设计理念下,面向过程考虑更多的是功能调用,而面向对象需要设计功能本身的抽象模型,然后再实现功能。考虑的多必然导致编码的冗余,但是这样的方式的好处是更容易描述整个应用的状态和功能。如果理解的不正确,希望大家指出。
改造过程中遇到的问题
在将Java项目中迁移到Go的过程中也会遇到各种各样的问题,书写上的习惯,功能设计上的差异等等。我把它分为了以下几个方面:
1.万物皆指针到值和指针的控制
提到值传递和指针传递,是不是让你想起了写C或者Cplus的青葱岁月。Java中只有基本类型是值传递之外(不包含基本类型的封装类)其他的都是引用传递,引用换句话说就是指针。传递指针的一个好处是,传递的是一个内存地址,因此在程序赋值的时候,只需要将内存地址复制一下即可,具体地址指向的内容的大小和内容是什么,根本不用关心,只有在使用的时候再关心即可。可以说Java本身就屏蔽了这么一个可能出现大量复制的操作。但是Go并没有给你屏蔽这种操作,这个时候你自己就需要根据自己的应用场景选择到底是选择传递值还是引用。
//Car我们定义一个车的基本信息,用来比较车与车之间的性价比typeCarstruct{NamestringPricefloat32TopSpeedfloat32Accelerationfloat32}//CompareVa值传递,此时会存在Car所有的数据复制,低效funcCompareVa(aCar,bCar){//TODO...compare}//ComparePtr指针传递,只是复制了地址,内容不会复制,高效funcComparePtr(a*Car,b*Car){//TODO...compare}
2.精简的语法导致的不注意引起的局部变量的创建
var(currentItemInfo*itemInfoemptyKey=""dbCollectormetrics.CollectorInterface//我们定义了一个全局变量,数据上传的hook)//用于初始化我们的定义的db打点收集器funcinitMetrics(){dbCollector:=metrics.NewCollector(&metrics.MetricOptions{Name:metrics.MetricTypeMyql,Interval:time.Minute,})dbCollector.Register(itemTypeConnection,&rawOperation{},&itemConnection{})......dbCollector.Start()}
不知道大家有没有发现其中的问题?
initMetrics()
方法并没有完成自己的任务,dbCollector变量并没有被初始化。只是因为我们使用了:=。此时应用只是重新创建了一个局部变量而已,语法正确,IDE并不会给我们做出提示。因此,精简的语法带来了代码的整洁,随之而来的需要我们更加专注于自己写的代码,仔细检查自己打的每一个字符。
3.理解nil和null和空
nil只是Go语言中指针的空地址,变量没有被分配空间
null只是Java语言中引用的空地址,变量没有被分配空间
空就是分配了内存,但是没有任何内容
4.关于string
习惯了Java中对于String的使用方式,在Go中使用string的时候会稍微有点儿不习惯。Java中String是引用类型,而在Go中就是一个基本类型。
Java代码
Stringstr;//定义了一个java变量,初始化为null
Go代码
strstring//定义了一个go变量,初始化为空字符串,注意这里不是nil
5.没有包装类
我们经常会在Java工程当中写这样的代码
classModel{publicIntegerminLspri;publicIntegermaxLspri;...}publicMapgenerateParam(Modelparam){Mapparams=Maps.newHashMap();if(param.minLspri!=null){params.put("minLspri",param.minLspr.toString())}if(param.minLspri!=null){params.put("maxLspri",param.maxLspri.toString())}...}
那我们在改造为Go的时候要不要直接转化为这样
typeModelstruct{minLspri*intmaxLspri*int...}...
遇到这种问题怎么办?我的建议是我们还是直接定义为
typeModelstruct{minLspriintmaxLspriint...}
我们还是要像Go一样去写Go,而不是Java味道的Go项目。而出现这个问题的原因我也想了一下,其实就是在java项目当中,我们习惯的会把null作为一个值来理解,其实null是一种状态,而不是值。它只是告诉你变量的状态是还没有被分配内存,而不是变量是null。所以在改造这种项目的过程中,还是要把每个字段的默认值和有效值了解清楚,然后做判断即可。
6.数据库NULL字段的处理
这个其实也是因为上一条原因导致的,那就是Go中没有包装器类型,但好在sql包中提供了sql.NullString这样的封装器类型,让我们更好的判断到底数据库中存放的是一个特定的值还是保存为null
Java和Go在处理key不存在的时候方式不一样。Java中Key不存在就是返回一个空字符串,但是Go中如果Key不存在的话,返回的其实是一个error。因此我们在Go中一定要把其他的错误和key不存在的error区分开。
8.异常的处理和err处理
Java中的Exception记录了太多的东西,包含了你的异常的调用链路和打印的日志信息,在任何catch住的异常那里都很方便的把异常链路打印出来。而Go中处理方式更简洁,其实只是记录了你的异常信息,如果你要看异常堆栈需要你的特殊处理。这就需要你在任何出现error的地方及时的打印日志和作出处理,而不是像Java一样,我在最外层catch一下,然后处理一样也可以很潇洒了。孰是孰非,只能在不断的学习和理解当中来给出答案了。
接下来我们会在Ngo上继续增加流量跟踪标识、全链路数据上报等特性,并完善监控指标,陆续推动更多Java语言业务转入Go语言。