网易传媒Go语言探索
网易传媒于2020年底开始尝试Go语言的探索,用于解决内存资源使用率偏高,编译速度慢等问题,本文将详细描述传媒在Go语言方面的所做的工作和取得的收益。

1 Go语言介绍
编译速度快
语法简单
像动态语言一样开发
def biu(toy):
toy.roll()
o = new_ball()
roll(o)
type ReadIF interface {
Read()
}
type WriteIF interface {
Write()
}
type ReadWriteIF interface {
ReadIF
WriteIF
}
func rw(i ReadWriteIF) {
i.Read()
i.Write()
}
type File struct{}
func (*File) Read(){}
func (*File) Write(){}
rw(&File{})
资源消耗少
Java需要启动JVM进程来运行中间代码,程序需要预热 堆内存较大时,垃圾回收器需要进行人工深入调优,但在一些对实时性要求高的场景下,可能无解,Full GC一触发就是灾难 JDK体积庞大, Spring Boot jar包体积大,在微服务架构下问题最突出 Spring全家桶越来越重,导致使用全家桶的应用,性能较差
为并发IO而生



func process(client *RPCClient) {
response := client.Call() // 阻塞
compute(response) // CPU密集型业务
}
func main() {
client := NewRPCClient()
for i := 0; i < 100; i++ {
go process(client)
}
select {} //死等
}
listener := Listen("127.0.0.1:8888")
for {
conn := listenser.Accept() // 阻塞直至连接到来
go func() { // 对每个连接启动一个goroutine做同步处理
for {
req := conn.Read()
go func() { // 将耗时处理放入新的goroutine,不阻塞连接的读取
res := compute(req)
conn.Write(res)
}()
}
}()
}
可运维性好
GOOS=linux GOARCH=amd64 go build ./...
与C/C++兼容
/*
#include
void myprint() {
printf("hi~");
}
*/
import "C"
C.myprint()
统一而完备的工具集
go run:直接运行go代码文件 go build:编译到本目录 go install:编译并安装到统一目录,比build快 go fmt:格式化代码,写完代码一定要记得用 go get:下载并安装包和依赖库 go mod:包管理,1.11版加入 go test:运行单元测试 go doc:将代码注释输出成文档 go tool:实用工具集,包括静态错误检查、测试覆盖率、性能分析、生成汇编等等
2 Ngo框架介绍
背景
选型
HTTP Server的性能不理想 缺乏大量业务所需库,比如kafka、redis、rpc等,如果在其基础上开发不如从零选择更适合的库 大部分库无法注入回调函数,也就难以增加无感的哨兵监控 若干模块如ORM不够好用
目标
提供比原有Java框架更高的性能和更低的资源占用率 尽量为业务开发者提供所需的全部工具库 嵌入哨兵监控,自动上传监控数据 自动加载配置和初始化程序环境,开发者能直接使用各种库 与线上的健康检查、运维接口等运行环境匹配,无需用户手动开发配置
主要功能模块

HTTP Server
Url哨兵监控 可跟踪的goroutine,防止goroutine泄露和不安全停止 服务健康检查的全部接口,包括优雅停机 用户回调函数的panic处理和上报
func main() {
s := server.Init()
s.AddRoute(server.GET, "/hello", func(ctx *gin.Context) {
ctx.JSON(protocol.JsonBody("hello"))
})
s.Start()
}
优雅停机
online:流量灰度中容器上线时调用,允许服务开始接受请求 offline:流量灰度中容器下线时调用,关闭服务,停止进程内所有后台业务 check:提供k8s liveness探针,展示当前进程存活状态 status:提供k8s readiness探针,表明当前服务状态,是否能提供服务
MySQL ORM
自动读取配置并初始化MySQL ORM客户端,配置中可以包含多个客户端 mysqlCollector哨兵监控
日志
统一简洁的定制化格式输出,包含日志的时间、级别、代码行数、函数、日志体 可选按txt或json格式输出日志 access、info、error日志分离到不同文件中 提供文件轮转功能,在日志文件达到指定大小或寿命后切换到新文件
panic fatal error warning info debug
Redis
Kafka
consumer.Start(func(message *sarama.ConsumerMessage) {
// 消费代码
})
HTTP Client
设置url query 设置请求body,body的格式支持任意对象json序列化、[]byte、x-www-form-urlencoded的key-value形式 解析回复的header 解析回复的body,body格式支持任意对象json序列化、int、string、float、[]byte 请求超时设置 service mesh的降级回调
RPC
1连接场景RPC性能是HTTP的100倍左右 5连接场景RPC 性能是HTTP的40-70倍 RPC的5连接是HTTP的100连接性能的3-4倍
配置
服务属性 日志 哨兵nss 哨兵收集器 Redis MySQL Kafka HTTP Client
service:
serviceName: service1
appName: testapp
clusterName: cluster1
nss:
sentryUrl: http://www.abc.com
httpServer:
port: 8080
mode: debug
log:
path: ./log
level: info
errorPath: ./error
db:
- name: test
url: root:@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True&loc=Local
httpClient:
maxConnsPerHost: 41
maxIdleConnDuration: 50s
redis:
- name: client1
connType: client
addr: 1.1.1.1
kafka:
- name: k1
type: consumer
addr:
- 10.1.1.1:123
- 10.1.1.2:123
topic:
- test1
- test2
- name: k2
type: producer
addr: 10.1.2.1:123
topic: test
哨兵
collector = metrics.NewCollector(&metrics.MetricOptions{
Name: metricName,
Interval: time.Minute,
})
collector.Register(itemTypeInvocation, &RawData{}, &ItemData1{})
collector.Register(itemTypeHostInvocation, &RawData{}, &ItemData2{})
collector.Start()
httpClient4 Url redis Exception mysqlCollector kafkaBase
3 性能压测及线上表现
压测比较
第一轮压测指标:
100并发 10分钟

首先我们先看一下整体的不同项目的集群整体表现


TPS-RT曲线


请求曲线


机器性能指标

Go集群

从当前的压测结果和机器性能指标来看,Go集群有更好的并发请求处理能力,请求吞吐量更大,并且在机器资源占用上有更好的优势。使用更少的内存,做了更多的事情。
第二轮压测指标:
200并发
10分钟
集群配置:

首先我们先看一下整体的不同项目的集群整体表现
Java 集群

Go集群

TPS-RT曲线
Java集群

Go集群

各项指标曲线和100并发状态相似,除了TPS曲线。Java 在200并发下冷起的过程变得更长了。但最终都还是趋于稳定的状态。
请求曲线
Java集群

Go集群

此时反而发现Go集群增压的情况下抖动较上次没有什么变化,反而Java集群的建立连接时间抖动变大了。
机器性能指标
cpu-memory
Java 集群

Go集群

机器资源曲线没有太大的变化。
总结:
100并发

200并发

从两次结果压测结果来看的话,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接口,该业务主要是获取文章详情页下边的热门标签。编码逻辑相对简单,服务调用只是涉及到了redis缓存的读取。目前的已经全量上线状态。
第二个接口是获取文章的相关推荐。编码逻辑中会通过http对推荐系统接口做请求,然后将数据缓存,优先获取缓存中的数据。目前全量上线。
第三个接口主要是获取网易号相关的tab标签。编码逻辑中会通过网易号在数据库中读取网易号的配置数据,然后做缓存,下次请求优先使用缓存。而且还需要通过http来调用大象系统,获取与该网易号相关的tab标签,而后将数据整合后返回到端上。
hotTag接口表现

机器资源状态

推荐接口表现

机器资源状态

结论:
就目前的线上集群的状态来看的话,集群的运行状态比较稳定,而且服务的处理能力是极为高效的。当然了,目前的线上状态Go项目接口单一,整个集群就只有这一个接口提供服务。Java集群因为业务关系,提供的服务接口更多,而且性能表现可能会因为系统IO或者网络带宽问题,导致了性能的看上去没有那么漂亮,更准确的结论会在Java集群中的所有接口全部迁移到Go集群中的时候的数据表现更具有说服力。
4 重构实践与问题
Go 协程与 Java的线程
Go为了更加合理分配计算机的算力,使用更为轻量级的协程替代线程。协程和线程之间的运行原理大家可以参考文章前边对于协程的讲解,或者自行百度。此处只讲解在写应用的过程中,我们在代码级别能得到什么样的好处。
talk is cheap, show my the code!
Go 使用协程
// GoN 在后台使用goroutine启动多个函数,并等待全部返回
func GoN(functions ...func()) {
if len(functions) == 0 {
return
}
var wg sync.WaitGroup
for _, function := range functions {
wg.Add(1)
go func(f func()) {
defer wg.Done()
f()
}(function)
}
wg.Wait()
}
// 使用协程来执行
util.GoN(
func() {
topicInfo = GetTopicInfoCachable(tid)
},
)
Java 使用线程
//当然了,我们知道很多种java的线程实现方式,我们就实现其中的一种
// 定义 功能类
private CompletableFuture getTopicInfoFuture(String tid) {
return CompletableFuture.supplyAsync(() -> {
try {
return articleProviderService.getTopicInfo(tid);
} catch (Exception e) {
log.error("SubscribeShortnewsServiceImpl.getTopicInfoFuture tid: {}", tid, e);
}
return null;
}, executor);
}
// 线程使用
CompletableFuture topicInfoFuture = getTopicInfoFuture(tid);
TopicInfo topicInfo = null;
try {
topicInfo = topicInfoFuture.get(2, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("[SubscribeShortnewsServiceImpl] getSimpleSubscribeTopicHead future error, tid = " + tid, e);
}
总结:
从上述的代码实现中,我们可以看出来Java代码的书写过程略显冗余,而且被线程执行的过程是需要被实现为特定的类,需要被继承覆盖或者重写的方式来执行线程。想要复用已经存在功能函数会费些周折。但是Go在语法级别支持了协程的实现,可以对已经实现功能做到拿来即可使用,哪怕没有对这个功能做封装。
我个人理解是因为语言的实现理念导致了这种书写方式上的差异。本身Go就是类C语言,它是面向过程的编程方式,而Java又是面向对象编程的优秀代表。因此在不同的设计理念下,面向过程考虑更多的是功能调用,而面向对象需要设计功能本身的抽象模型,然后再实现功能。考虑的多必然导致编码的冗余,但是这样的方式的好处是更容易描述整个应用的状态和功能。如果理解的不正确,希望大家指出。
改造过程中遇到的问题
在将Java项目中迁移到Go的过程中也会遇到各种各样的问题,书写上的习惯,功能设计上的差异等等。我把它分为了以下几个方面:
1.万物皆指针到值和指针的控制
提到值传递和指针传递,是不是让你想起了写C或者C plus的青葱岁月。Java中只有基本类型是值传递之外(不包含基本类型的封装类)其他的都是引用传递,引用换句话说就是指针。传递指针的一个好处是,传递的是一个内存地址,因此在程序赋值的时候,只需要将内存地址复制一下即可,具体地址指向的内容的大小和内容是什么,根本不用关心,只有在使用的时候再关心即可。可以说Java本身就屏蔽了这么一个可能出现大量复制的操作。但是Go并没有给你屏蔽这种操作,这个时候你自己就需要根据自己的应用场景选择到底是选择传递值还是引用。
// People 我们定义一个车的基本信息,用来比较车与车之间的性价比
type Car struct {
Name string
Price float32
TopSpeed float32
Acceleration float32
}
// CompareVa 值传递,此时会存在Car所有的数据复制,低效
func CompareVa(a Car, b Car){
// TODO ... compare
}
// ComparePtr 指针传递,只是复制了地址,内容不会复制,高效
func ComparePtr(a *Car, b *Car){
// TODO ... compare
}
2.精简的语法导致的不注意引起的局部变量的创建
var dbCollector metrics.CollectorInterface // 我们定义了一个全局变量,数据上传的hook
// 用于初始化我们的定义的db打点收集器
func initMetrics() {
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 代码
Go代码
5.没有包装类
我们经常会在Java工程当中写这样的代码
class Model {
public Integer minLspri;
public Integer maxLspri;
...
}
public Map generateParam(Model param) {
Map params = Maps.newHashMap();
if( param.minLspri != null ){
params.put("minLspri", param.minLspr.toString())
}
if( param.minLspri != null ){
params.put("maxLspri", param.maxLspri.toString())
}
...
}
那我们在改造为Go的时候要不要直接转化为这样
type Model struct {
minLspri *int
maxLspri *int
...
}
...
遇到这种问题怎么办?我的建议是我们还是直接定义为
type Model struct {
minLspri int
maxLspri int
...
}
我们还是要像Go一样去写Go,而不是Java味道的Go项目。而出现这个问题的原因我也想了一下,其实就是在java项目当中,我们习惯的会把null作为一个值来理解,其实null是一种状态,而不是值。它只是告诉你变量的状态是还没有被分配内存,而不是变量是null。所以在改造这种项目的过程中,还是要把每个字段的默认值和有效值了解清楚,然后做判断即可。
6.数据库NULL字段的处理
这个其实也是因为上一条原因导致的,那就是Go中没有包装器类型,但好在sql包中提供了 sql.NullString 这样的封装器类型,让我们更好的判断到底数据库中存放的是一个特定的值还是保存为null
7.redis 相关的sdk原生的处理方式的不同
Java和Go在处理key不存在的时候方式不一样。Java中Key不存在就是返回一个空字符串,但是Go中如果Key不存在的话,返回的其实是一个error。因此我们在Go中一定要把其他的错误和key不存在的error区分开。
8.异常的处理和err处理
Java中的Exception记录了太多的东西,包含了你的异常的调用链路和打印的日志信息,在任何catch住的异常那里都很方便的把异常链路打印出来。而Go中处理方式更简洁,其实只是记录了你的异常信息,如果你要看异常堆栈需要你的特殊处理。这就需要你在任何出现error的地方及时的打印日志和作出处理,而不是像Java一样,我在最外层catch一下,然后处理一样也可以很潇洒了。孰是孰非,只能在不断的学习和理解当中来给出答案了。
接下来我们会在Ngo上继续增加流量跟踪标识、全链路数据上报等特性,并完善监控指标,陆续推动更多Java语言业务转入Go语言。
Ngo GitHub地址: