写点什么

网易传媒 Go 语言探索

作者:雷霆
  • 2022 年 2 月 08 日
  • 本文字数:12227 字

    阅读完需:约 40 分钟

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



网易传媒于 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 核数配置开启多个。


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

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


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


为了偷懒我们倾向于避免使用 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 <stdio.h>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 系接口更接近。整个业务服务的架构如下图所示:



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,避免日志过多影响性能。

另外在日志输出时可以使用WithFieldWithFields来字段的 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: cluster1nss:  sentryUrl: http://www.abc.comhttpServer:  port: 8080  mode: debuglog:  path: ./log  level: info  errorPath: ./errordb:  - name: test    url: root:@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True&loc=LocalhttpClient:  maxConnsPerHost: 41  maxIdleConnDuration: 50sredis:  - name: client1    connType: client    addr: 1.1.1.1kafka:  - 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 分钟

集群配置:



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

Java 集群



Go 集群


TPS-RT 曲线

Java 集群



Go 集群



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

 

请求曲线

Java 集群



Go 集群



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

机器性能指标

cpu-memory

Java 集群


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<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 地址:

https://github.com/NetEase-Media/ngo


发布于: 刚刚阅读数: 3
用户头像

雷霆

关注

还未添加个人签名 2020.04.12 加入

还未添加个人简介

评论

发布
暂无评论
网易传媒Go语言探索