网易传媒Go语言探索


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

网易传媒于2020年将核心业务全部迁入容器,并将在线业务和离线业务混部,CPU利用率提升到了50%以上,取得了较大的收益,但在线业务方面,接入容器后仍存在一些问题:

  1. 在线业务内存使用量偏高:传媒主要开发语言是Java,使用SpringBoot框架,普遍内存使用量都在2G以上,和Go语言相比,占用内存资源很大。
  2. 在线业务编译速度和启动速度偏慢,占用空间较大:由于使用Java,JVM在镜像实例都需要上百兆的空间,同时,SpringBoot在编译速度和启动速度和Go语言相比,都比较慢。

Go语言于2009年由Google推出,经过了10多年的发展,目前已经有很多互联网厂商都在积极推进Go语言应用,网易传媒于2020年底开始尝试Go语言的探索,用于解决内存资源使用率偏高,编译速度慢等问题。本文将详细描述传媒在Go语言方面的所做的工作。

1 Go语言介绍

相比1995年发布的Java,Go语言是一个比较年轻的语言。年轻带来了正反两方面的结果。从好的一方面来说,Go吸取了过去多种语言的优点,也没有C++这种悠久历史的语言向前兼容的桎梏;另一方面,Go因为出现的时间不算长,编译器、运行时、语法等都还在不断调整和优化,还未达到类似Java的成熟状态,而且开源类库也比不上诸如Python的老语言。

但是瑕不掩瑜,下面就来谈谈Go语言有哪些特性吸引我们去使用。

编译速度快

从其它静态语言转到Go的开发者最先体验到的可能就是编译的速度。一般来说,Go的编译速度比Java和C++快5倍以上。很多C++大型项目可能需要编译10分钟以上,而相同规模的Go项目很可能1分钟都不到。这个特性使代码编写者可以轻易用go run迅速编译测试,甚至直接开启IDE的自动后台单测,在多人开发迭代时CI/CD的时间基本上只够去一次厕所。

这种特性的主要原因官方文档里已经提到了:Go编译模型让依赖分析更简单,避免类C语言头文件和库的很多开销。不过这个也引入了一个束缚——包之间无法递归依赖,如果遇到类似的问题只能通过提取公共代码或者在外部初始化包等等方式来解决。

语法简单

Go语言起源于Google中一次C++新特性的分享会,一伙人(包括C语言创始人、UTF8发明人、V8 JS引擎开发者)觉得C++实在是太臃肿,索性创造一种语言来简化编程。因为Google内部员工主要使用类C语法的语言,所以Go也基本保持了几乎与C一致的语法,只要学过C就非常容易上手。另外由于Go在语法上吸取了各种语言多年的经验教训,各方面都有不少让人眼前一亮的小优化。

像动态语言一样开发

使用过动态语言的应该接触过下面这种Python代码:

def biu(toy):
    toy.roll()

o = new_ball()
roll(o)

roll函数可以传入任何类型的对象,这种动态语言特征使开发及其灵活方便。但是大家可能都听说过动态语言重构比较麻烦,类似的实现会给其它维护者造成很大的障碍,如果不是这个原因Python3也就不会加入type hints的特性了。

那么有没有既能使用动态类型,又能限制传入的对象类型的方式呢?Go的interface就是用来解决这个问题的。interface类似一个强制性的泛化type hints,虽然它不强求特定的类型,但对象必须满足条件。下面看个简单的例子,首先声明了两种interface,并将它们组合成ReadWriteIF:

type ReadIF interface {
    Read()
}
type WriteIF interface {
    Write()
}
type ReadWriteIF interface {
    ReadIF
    WriteIF
}

接下来使用这个interface,注意只要一个对象的类型满足interface里的全部函数,就说明匹配上了。

func rw(i ReadWriteIF) {
    i.Read()
    i.Write()
}
type File struct{}
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之类一系列跨平台工具,这种卖点可能被看做一种短板,主要原因如下:

  • Java需要启动JVM进程来运行中间代码,程序需要预热
  • 堆内存较大时,垃圾回收器需要进行人工深入调优,但在一些对实时性要求高的场景下,可能无解,Full GC一触发就是灾难
  • JDK体积庞大, Spring Boot jar包体积大,在微服务架构下问题最突出
  • Spring全家桶越来越重,导致使用全家桶的应用,性能较差

抛去JVM启动和预热时间,运行一个最简单的HTTP程序,与Go对比,Java在CPU上的消耗多约20%,内存上的消耗约高两个数量级。

为并发IO而生

练习过开发网络库的读者可能都知道Unix的epoll系统调用,如果了解Windows应该听说过IOCP,这两种接口分别对应网络的Reactor和Proactor模式。简单来说前者是同步的事件驱动模型,后者是异步IO。不论你使用任何语言只要涉及到高性能并发IO都逃不过这两种模式开发的折磨——除了Go。

为了展示使用Go开发并发IO有多么简单,我先从大家熟悉的普通程序的线程模型讲起。下图是一个常见的程序线程图,一般来说一个服务进程包含main、日志、网络、其他外部依赖库线程,以及核心的服务处理(计算)线程,其中服务线程可能会按CPU核数配置开启多个。
网易传媒Go语言探索_第2张图片

服务启动后RPC请求到来,此请求的发起端可能是客户端或者另一个服务,那么它在服务线程处理过程中将阻塞并等待回复事件。注意这里的RPC包含广义上的网络协议,比如HTTP、Redis、数据库读写操作都属于RPC。

此时的情况就如下图所示,服务调用端的请求要经过网络往返和服务计算的延迟后才能获得结果,而且服务端很可能还需要继续调用其它服务。

在这里插入图片描述

大多数开发者都会想:反正调用一般也就几十毫秒嘛,最多到秒级,我开个线程去同步等待回复就行,这样开发最方便。于是情况就会变成下图这样,每个请求占用一个连接和一个线程。如果网络和计算延迟加大,要保持服务器性能被充分利用,就需要开启更多的连接和线程。
网易传媒Go语言探索_第3张图片

为了偷懒我们倾向于避免使用Reactor和Proactor模式,甚至都懒得去了解它们,就算有人真的希望优化并发IO,类似Jedis这种只支持同步IO的库也能阻止他。

现在有Go能拯救我们了,在Go里没有线程的概念,你只需要知道使用go关键字就能创建一个类似线程的goroutine。Go提供了用同步的代码来写出异步接口的方法,也就是说我们调用IO时直接像上图期望的一样开发就行,Go在后台会调用epoll之类的接口来完成事件或异步处理。这样就避免了把代码写得零碎难懂。下面展示一个简单的RPC客户端例子,RPC调用和后续的计算处理代码可以顺畅地写在一起放入一个goroutine,而这段代码背后就是一个epoll实现的高性能并发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 {} //死等
}

服务器的代码更简单,不需要再去监听事件,当获取到一个IO对象时,只要使用go就能在后台开启一个新的处理流程。

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)
            }()
        }
    }()
}

注意go创建的goroutine相当于将IO读写和事件触发拼接起来的一个容器,消耗的内存非常小,所有goroutine被Go自动调度到有限个数的线程中,运行中切换基本是使用epoll的事件机制,因此这种协程机制可以很迅速启动成千上万个而不太消耗性能。

可运维性好

随着虚拟化技术发展,类似JVM的服务成为了一种累赘;因为磁盘空间大小不再是问题,动态库带来的兼容问题也层出不穷,因此它也在慢慢淡出视野。

Go是一种适应分布式系统和云服务的语言,所以它直接将静态编译作为默认选项,也就是说编译之后只要将可执行文件扔到服务器上或者容器里就能没有任何延迟地运行起来,不需要任何外部依赖库。

此外Go的项目只要在编译时修改参数,就能交叉编译出其他任意支持平台所需的二进制文件。比如我几乎完全在macOS上开发,当需要在linux服务器上测试则使用如下命令编译:

GOOS=linux GOARCH=amd64 go build ./...

Go支持android、darwin、freebsd、linux、windows等等多种系统,包括386、amd64、arm等平台,绝大部分情况下你可以在自己的笔记本上调试任意系统平台的程序。

与C/C++兼容

由于没有虚拟机机制,Go可以与C语言库比较轻易地互相调用。下面是一个简单的例子,直接在Go中调用C语句:

/*
#include 
void myprint() {
        printf("hi~");
}
*/

import "C"
C.myprint()

如果使用Go编写一个接口,然后使用go build -buildmode=c-shared编译,这样就能得到一个动态库和一个.h头文件,怎么使用就无需再解释了吧。

统一而完备的工具集

Go作为工程语言而设计,它的目标就是统一,即使一个团队有多种风格的开发者,他们的流程和产出最终都需要尽量保持一致,这样协同开发效率才会高。为了保证各方面的统一,Go提供了多种工具,安装以后执行go命令就能直接使用。

  • go run:直接运行go代码文件
  • go build:编译到本目录
  • go install:编译并安装到统一目录,比build快
  • go fmt:格式化代码,写完代码一定要记得用
  • go get:下载并安装包和依赖库
  • go mod:包管理,1.11版加入
  • go test:运行单元测试
  • go doc:将代码注释输出成文档
  • go tool:实用工具集,包括静态错误检查、测试覆盖率、性能分析、生成汇编等等

2 Ngo框架介绍

背景

在传媒技术团队中推广Go语言,亟需一个Web框架提供给业务开发同事使用,内含业务开发常用库,避免重复造轮子影响效率,并且需要无感知的自动监控数据上报,于是就孕育出Ngo框架。

选型

由于Go的开源Web框架没有类似Spring Boot大而全的,而最大的框架也是很受用户欢迎的框架是Beego,为什么没有直接使用Beego呢?主要有以下几个原因:

  • HTTP Server的性能不理想
  • 缺乏大量业务所需库,比如kafka、redis、rpc等,如果在其基础上开发不如从零选择更适合的库
  • 大部分库无法注入回调函数,也就难以增加无感的哨兵监控
  • 若干模块如ORM不够好用

目标

Ngo是一个类似Java Spring Boot的框架,全部使用Go语言开发,主要目标是:

  • 提供比原有Java框架更高的性能和更低的资源占用率
  • 尽量为业务开发者提供所需的全部工具库
  • 嵌入哨兵监控,自动上传监控数据
  • 自动加载配置和初始化程序环境,开发者能直接使用各种库
  • 与线上的健康检查、运维接口等运行环境匹配,无需用户手动开发配置

注:哨兵是网易杭研运维部开发的监控系统,提供实时数据分析、丰富的监控指标和直观的报表输出。

主要功能模块

Ngo避免重复造轮子,所有模块都是在多个开源库中对比并挑选其一,然后增加部分必需功能,使其与Java系接口更接近。整个业务服务的架构如下图所示:
网易传媒Go语言探索_第4张图片

HTTP Server

高性能的gin实现了框架最重要的HTTP Server组件。用户在使用Ngo时无需关心gin的配置和启动,只需注册http route和对应的回调函数。在gin之上Ngo还提供以下功能:

  • Url哨兵监控
  • 可跟踪的goroutine,防止goroutine泄露和不安全停止
  • 服务健康检查的全部接口,包括优雅停机
  • 用户回调函数的panic处理和上报

下面是一个简单的main函数实例,几行代码就能实现一个高性能http server。

func main() {
	s := server.Init()
	s.AddRoute(server.GET, "/hello", func(ctx *gin.Context) {
		ctx.JSON(protocol.JsonBody("hello"))
	})
	s.Start()
}

优雅停机

服务健康检查接口包括4个/health下的对外HTTP接口:

  • online:流量灰度中容器上线时调用,允许服务开始接受请求
  • offline:流量灰度中容器下线时调用,关闭服务,停止进程内所有后台业务
  • check:提供k8s liveness探针,展示当前进程存活状态
  • status:提供k8s readiness探针,表明当前服务状态,是否能提供服务

offline接口实现了优雅停机功能,可以让进程在不停止的情况下停止服务,不影响已收到且正在处理的请求,直至最后请求处理完毕再停机。当平台通知服务需要停止服务时,优雅停机功能会停止本进程正在运行的全部后台业务,当所有任务都停止后,offline接口的返回值会告诉平台已准备好下线,此时才允许停机。如果服务出现某些故障,导致处理请求的任务阻塞,此功能会在一段时间内尝试停止,如果超时才会强制关闭。

MySQL ORM

使用gorm实现MySQL ORM的功能,并在之上提供以下功能:

  • 自动读取配置并初始化MySQL ORM客户端,配置中可以包含多个客户端
  • mysqlCollector哨兵监控

日志

使用logrus实现日志接口,并提供以下功能:

  • 统一简洁的定制化格式输出,包含日志的时间、级别、代码行数、函数、日志体
  • 可选按txt或json格式输出日志
  • access、info、error日志分离到不同文件中
  • 提供文件轮转功能,在日志文件达到指定大小或寿命后切换到新文件

服务默认输出txt的日志格式,样式如:时间 [级别] [代码目录/代码文件:行数] [函数名] [字段键值对] 日志体。

时间格式类似2021-01-14 10:39:33.349。

级别包含以下几种:

  • panic
  • fatal
  • error
  • warning
  • info
  • debug

如果未设置级别,被被默认设置为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(message string)传入字符串即可上报数据,无需关心结果。此接口是异步操作,会立即返回。如果出错,后台会重试多次,并将最后的结果记录上传到哨兵监控。

Kafka消费者只需这样调用Start注册处理函数即可工作:

consumer.Start(func(message *sarama.ConsumerMessage) {
	// 消费代码
})

HTTP Client

HTTP Client使用fasthttp实现,提供相当卓越的性能。考虑到fasthttp提供的接口非常简单,用户必须自己格式化请求和回复的header和body,因此在其基础上做了大量开发,增加诸如Get(“xxx”).SetHead(h).SetBody(b).BindInt(i).Timeout(t).Do()的Java链式调用,包括:

  • 设置url query
  • 设置请求body,body的格式支持任意对象json序列化、[]byte、x-www-form-urlencoded的key-value形式
  • 解析回复的header
  • 解析回复的body,body格式支持任意对象json序列化、int、string、float、[]byte
  • 请求超时设置
  • service mesh的降级回调

RPC

由于gRPC的使用比较复杂,而且性能与Go标准库的RPC差距不大, 因此当前RPC库在Go标准库的基础上开发,并在之上增加连接池、连接复用、错误处理、断开重连、多host支持等功能。在使用上接口与标准库基本一致,因此没有学习成本。

至于使用RPC而不只限制于HTTP的主要原因,一是基于TCP的RPC运行多请求复用连接,而HTTP需要独占连接;二是HTTP在TCP之上实现,header占据了大量overhead,特别在小请求中是不必要的开销。在Ngo的两个库下自带性能测试,运行go test -bench .就能查看结果,两者都使用20*CPU的并发量,模拟1ms、5ms、50ms的服务器网络和计算延迟,具体结果如下:

  • 1连接场景RPC性能是HTTP的100倍左右
  • 5连接场景RPC 性能是HTTP的40-70倍
  • RPC的5连接是HTTP的100连接性能的3-4倍

配置

配置模块使用viper实现,但用户无需调用配置模块的接口,在每个模块如Redis、Kafka、日志中都会被Ngo自动注入配置,用户只需写好yaml文件即可。

服务需要提供-c conf参数来指定配置文件,启动时,会依次加载以下配置:

  • 服务属性
  • 日志
  • 哨兵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

哨兵

哨兵模块的目的是提供统一且易扩展的接口,适配哨兵数据的收集方式,将各类数据上报到哨兵服务器。它包含两部分:数据收集和数据发送。

数据发送部分在程序启动时会加载当前服务的配置,设定好上报格式,当有收集器上报数据时会调用其接口生成固定的json格式,并使用HTTP Client库上报。

数据收集部分是一个可扩展的库,可以用其创建自定义的收集器,并指定metric和上报间隔,在Redis、Kafka、HTTP Client等库中都已经内置了收集器。一般来说一个收集器的处理行为只需要一种类型的数据来触发,在后台生成多种数据项。比如HTTP Client是每次都传入单次调用的记录,在收集器后台处理时生成对一分钟内所有调用的全汇总、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:

  • httpClient4
  • Url
  • redis
  • Exception
  • mysqlCollector
  • kafkaBase

3 性能压测及线上表现

技术的转型,势必会带来性能表现的差异,这也是我们为什么花费精力来探究的第一因。现在我们将从以下几个维度来对比一下转型为Go之后的所带来的优点和缺点

压测比较

压测最能体现出来在系统的极限的具体表现。因为语言本身的实现机制不同,Java因为Jvm的存在,因此两者的启动资源最小的阈值本身就不一样。我们压测的业务逻辑相对简单一些,业务中首先读取缓存数据,然后再做一次http调用,并将调用结果返回到端上。Java项目和Go项目框架内部集成了哨兵监控,都会将系统表现数据实时上报。我们参考的数据依据也是来自于此。

第一轮压测指标:

  • 100并发
  • 10分钟

集群配置:
网易传媒Go语言探索_第5张图片

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

Java 集群
网易传媒Go语言探索_第6张图片

Go集群
网易传媒Go语言探索_第7张图片

TPS-RT曲线

Java集群
网易传媒Go语言探索_第8张图片

Go集群
网易传媒Go语言探索_第9张图片

因为我们加压的过程是直接进入峰值,启动时候的表现,从TPS指标和MaxRT指标,显示Java集群有一个冷启动的过程,而Go集群没有这么一个过程。两者在经历过冷启动之后,性能表现都很稳定。

请求曲线

Java集群

网易传媒Go语言探索_第10张图片

Go集群
网易传媒Go语言探索_第11张图片

这里有一个很有意思的现象,那就是虽然Go有更大吞吐量,但是网络的建立时间并不是很稳定,而Java启动之后,则明显处于一个稳定的状态。

机器性能指标

cpu-memory

Java 集群
网易传媒Go语言探索_第12张图片

Go集群
网易传媒Go语言探索_第13张图片

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

第二轮压测指标:

  • 200并发
  • 10分钟

集群配置:
网易传媒Go语言探索_第14张图片

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

Java 集群

网易传媒Go语言探索_第15张图片

Go集群
网易传媒Go语言探索_第16张图片

TPS-RT曲线

Java集群
网易传媒Go语言探索_第17张图片

Go集群
网易传媒Go语言探索_第18张图片

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

请求曲线

Java集群
网易传媒Go语言探索_第19张图片

Go集群
网易传媒Go语言探索_第20张图片

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

机器性能指标

cpu-memory

Java 集群
网易传媒Go语言探索_第21张图片

Go集群
网易传媒Go语言探索_第22张图片

机器资源曲线没有太大的变化。

总结:

100并发
网易传媒Go语言探索_第23张图片

200并发
网易传媒Go语言探索_第24张图片

从两次结果压测结果来看的话,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语言探索_第25张图片

机器资源状态

网易传媒Go语言探索_第26张图片
推荐接口表现
网易传媒Go语言探索_第27张图片
机器资源状态
网易传媒Go语言探索_第28张图片

结论:

就目前的线上集群的状态来看的话,集群的运行状态比较稳定,而且服务的处理能力是极为高效的。当然了,目前的线上状态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<TopicInfo> 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<TopicInfo> 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 代码

String str; // 定义了一个java变量,初始化为null

Go 代码

str string  // 定义了一个go变量, 初始化为空字符串,注意这里不是nil

5.没有包装类

我们经常会在Java工程当中写这样的代码

class Model {
    public Integer minLspri;
    public Integer maxLspri;
    ...
}

public Map<String, String> generateParam(Model param) {
    Map<String, String> 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地址

你可能感兴趣的:(Go语言,golang)