最近把项目从Go 1.14升级到了Go 1.15和1.16,过程中踩了不少坑。今天记录一下升级过程中遇到的问题和解决方案,以及Go新版本的一些新特性和注意事项,希望能帮到大家。

先说说背景。我们公司有几个后端服务是用Go写的,之前一直用的是Go 1.14,运行得比较稳定。Go 1.15在2020年8月发布,Go 1.16在2021年2月发布(写这篇文章的时候是2020年2月,Go 1.16还没正式发布,但beta版已经出来了,我们提前做了测试)。

因为新版本有一些性能提升和新特性,比如改进的链接器、内嵌资源(embed)、模块-aware的命令等,我们决定把项目升级到新版本。本以为是一个简单的版本升级,没想到过程中踩了不少坑,花了大概一周的时间才完成升级和测试。

今天就把升级过程中遇到的问题、解决方案,以及新版本的一些新特性和注意事项记录下来,分享给大家。

一、Go 1.15的主要变化

先说说Go 1.15的主要变化。Go 1.15是一个比较大的版本,有很多改进,包括性能提升、工具链改进、标准库变化等。

1. 链接器改进

Go 1.15最大的变化之一是链接器的改进。Go团队重写了链接器的内部架构,把链接器从基于C的实现迁移到了基于Go的实现,同时优化了链接器的性能和内存使用。

根据官方的benchmark,Go 1.15的链接速度比Go 1.14快了20%到30%,链接时的内存使用减少了30%左右。对于大项目来说,这个提升还是很明显的,我们的项目编译时间从40秒降到了30秒左右。

不过,新的链接器也带来了一些问题,比如某些特殊的链接选项可能不兼容,一些cgo项目可能会有链接错误。我们在升级的时候就遇到了一个链接错误,后来发现是因为我们用了一个比较老的cgo库,和新的链接器不兼容,升级了库的版本就好了。

2. 小对象分配优化

Go 1.15对内存分配器做了优化,改进了小对象(小于16字节)的分配性能。根据官方的说法,小对象的分配速度提升了大约10%,GC的压力也有所减少。

我们的项目中有很多小对象的分配(比如字符串、小的结构体等),升级之后,服务的内存使用减少了大约5%,GC的暂停时间也有所缩短。对于内存敏感的服务来说,这个优化还是很有价值的。

3. 核心库的改进

Go 1.15对标准库做了很多改进,比较重要的有:

  • time包:改进了时间解析的性能,解析时间字符串的速度提升了大约30%
  • crypto/tls包:默认拒绝了一些不安全的TLS配置,比如小于2048位的RSA证书
  • net/http包:改进了HTTP/2的性能,修复了一些bug
  • encoding/json包:改进了JSON编解码的性能,修复了一些边界情况的bug
  • regexp包:改进了正则表达式的匹配性能

这些改进虽然单个看起来不大,但加起来对整体性能还是有提升的。我们的服务升级之后,整体的QPS提升了大约5%,延迟也有所降低。

4. 其他变化

Go 1.15还有一些其他的变化:

  • 不再支持32位的 macOS(darwin/386和darwin/arm)
  • go命令默认启用了模块模式(GO111MODULE=on),不过在Go 1.15中还是可以通过设置GO111MODULE=auto来兼容GOPATH模式
  • 改进了vet工具,增加了一些新的检查规则
  • 改进了测试工具,支持了一些新的测试选项

二、Go 1.16的主要变化

再说说Go 1.16的主要变化。Go 1.16是在Go 1.15的基础上的进一步改进,有几个比较重要的新特性。

注意:写这篇文章的时候是2020年2月,Go 1.16还没正式发布(正式版是2021年2月发布的),但我们用beta版做了测试,所以这里说的是基于beta版的体验,正式版可能会有一些变化。

1. 内嵌资源(embed)

Go 1.16最大的新特性是embed包,可以把静态文件(比如HTML、CSS、JavaScript、配置文件等)直接内嵌到Go的二进制文件中。

在Go 1.16之前,如果想把静态文件打包到二进制文件中,需要用第三方工具(比如go-bindata、packr等),比较麻烦。Go 1.16内置了embed功能,只需要在变量上加上//go:embed指令,就可以把文件内嵌进来。

比如:

package main

import (
    "embed"
    "net/http"
)

//go:embed static
var staticFiles embed.FS

func main() {
    http.Handle("/", http.FileServer(http.FS(staticFiles)))
    http.ListenAndServe(":8080", nil)
}

这样,static目录下的所有文件都会被内嵌到二进制文件中,部署的时候只需要一个二进制文件就够了,不需要再带静态文件目录。

我们的项目中有一个管理后台,有很多静态文件(HTML、CSS、JS),之前部署的时候需要把静态文件目录一起上传,比较麻烦。用了embed之后,只需要部署一个二进制文件就够了,部署简单了很多,而且也不用担心静态文件路径的问题。

不过,embed也有一些注意事项:

  • //go:embed指令必须紧跟在变量声明的前面,中间不能有空行或者其他注释
  • 内嵌的文件是只读的,不能修改
  • 内嵌大文件会增加二进制文件的体积,要注意控制
  • 不能内嵌以"."或者"_"开头的文件(除非用*通配符)

2. 模块-aware的命令

Go 1.16中,go命令默认是模块感知的,也就是说,go install、go get、go run等命令,默认会在模块模式下运行,不需要再设置GO111MODULE=on。

而且,Go 1.16对go install命令做了改进,支持安装指定版本的工具:

go install golang.org/x/tools/gopls@v0.6.0

这样,可以直接安装指定版本的工具,不需要再用go get,也不会影响当前项目的go.mod文件。

另外,Go 1.16中,go get命令不再用于安装工具,而是只用于修改go.mod文件中的依赖。如果要安装工具,应该用go install。这个变化可能会让一些人不适应,因为之前大家习惯了用go get来安装工具。

我们在升级的时候,就遇到了这个问题。我们的CI脚本中有一些go get命令用来安装工具,升级到Go 1.16之后,这些命令的行为变了,导致CI失败。后来把go get改成了go install,并指定了版本,就好了。

3. 标准库的改进

Go 1.16对标准库也做了很多改进:

  • io/fs包:新增了io/fs包,定义了文件系统的接口,embed包和os包都实现了这个接口,可以统一处理不同来源的文件
  • net/http包:http.FileServer支持了io/fs.FS接口,可以直接用http.FS()把embed.FS转换成http.FileSystem
  • os包:增加了一些新的函数,比如os.ReadFile、os.WriteFile(其实这两个在Go 1.16之前就有了,不过Go 1.16做了一些优化)
  • crypto/sha256包:在ARM64架构上做了优化,SHA256的计算速度提升了很多
  • runtime包:改进了GC的性能,减少了GC的暂停时间

4. 其他变化

Go 1.16还有一些其他的变化:

  • 默认启用了Go模块模式,GO111MODULE的默认值变成了on(之前是auto)
  • 不再支持macOS 10.11及以下版本
  • 改进了测试工具,支持了-tags选项的多个标签(用逗号分隔)
  • 改进了vet工具,增加了一些新的检查规则

三、升级过程中踩过的坑

说了这么多新版本的变化,再说说我们在升级过程中踩过的坑。

坑1:go get安装工具的行为变化

这是我们遇到的第一个坑。如前所述,Go 1.16中,go get不再用于安装工具,而是只用于修改go.mod。我们的CI脚本中有很多这样的命令:

go get -u github.com/golang/mock/mockgen
go get -u github.com/swaggo/swag/cmd/swag

这些命令在Go 1.15及之前的版本中,是用来安装工具的。但在Go 1.16中,这些命令会尝试修改当前项目的go.mod文件,把这些工具作为依赖加进去,这不是我们想要的。而且,如果当前目录不是一个Go模块,这些命令还会报错。

解决方案:把go get改成go install,并指定版本:

go install github.com/golang/mock/mockgen@v1.4.4
go install github.com/swaggo/swag/cmd/swag@v1.6.7

这样,工具会被安装到GOPATH/bin目录下,不会影响当前项目的go.mod文件。

坑2:cgo库的链接错误

我们的项目中用了一个cgo库,用来和某个C语言的SDK交互。在Go 1.14中,这个库工作得很好,但升级到Go 1.15之后,链接的时候报错了,说找不到某个符号。

一开始我们以为是库的版本太老了,就升级了库的版本,但还是报错。后来仔细看了错误信息,发现是因为Go 1.15的新链接器对cgo的处理方式有变化,某些特殊的符号解析方式不支持了。

解决方案:在go build的时候,加上-ldflags="-linkmode=external"选项,强制使用外部链接器(系统的gcc),而不是Go自带的内部链接器。这样,链接的工作交给系统的gcc来做,就不会有兼容性问题了。

go build -ldflags="-linkmode=external" -o myapp .

不过,使用外部链接器会增加链接时间,而且需要系统安装了gcc。所以,如果不是必须,还是建议用内部链接器。我们后来找到了那个cgo库的更新版本,修复了和新链接器的兼容性问题,就不用外部链接器了。

坑3:TLS证书的兼容性问题

Go 1.15中,crypto/tls包默认拒绝了一些不安全的TLS配置,比如小于2048位的RSA证书、SHA1签名的证书等。

我们的项目中有一个服务,需要调用一个老的内部接口,这个接口用的是一个自签名的1024位RSA证书。在Go 1.14中,这个接口可以正常调用,但升级到Go 1.15之后,调用的时候报错了,说证书不安全。

解决方案:有两个选择,一个是升级那个老接口的证书,换成2048位以上的RSA证书;另一个是在代码中临时放宽TLS的验证(不推荐,不安全)。

我们选择了第一个方案,联系了那个接口的维护方,让他们升级了证书。升级之后,问题就解决了。

如果确实需要临时兼容老证书,可以在http.Transport中设置TLSClientConfig:

transport := &http.Transport{
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true, // 不推荐,仅用于测试
    },
}

但这个选项会跳过所有的TLS验证,很不安全,不建议在生产环境中使用。

坑4:JSON编解码的行为变化

Go 1.15对encoding/json包做了一些改进,修复了一些bug,但也导致了一些行为变化。

我们的项目中有一个地方,用json.Unmarshal解析一个JSON字符串到一个map[string]interface{}中。在Go 1.14中,如果JSON中的数字是整数,解析后是float64类型(这是Go的标准行为,因为JSON中没有整数和浮点数的区分)。但在我们的代码中,有一个地方假设如果数字是整数,解析后应该是int类型(这其实是我们代码的bug,之前因为某些巧合没有触发)。

升级到Go 1.15之后,JSON解析的某些边界情况的行为有变化,导致我们的那个bug被触发了,程序panic了。

解决方案:修复代码中的bug,正确处理float64类型,在需要整数的地方做类型转换。

var data map[string]interface{}
json.Unmarshal([]byte(jsonStr), &data)

// 正确的处理方式
if v, ok := data["count"].(float64); ok {
    count := int(v)
    // ...
}

这个坑其实不是Go 1.15的问题,而是我们代码的问题,但升级触发了它。这也提醒我们,在升级版本之前,要做好充分的测试,确保代码的行为符合预期。

坑5:go mod tidy的行为变化

Go 1.16中,go mod tidy的行为有一些变化,它会更严格地检查依赖,移除未使用的依赖,添加间接依赖。

我们的项目中有一些依赖,虽然在代码中没有直接引用,但在某些构建标签(build tag)下会用到,或者是测试代码中用到的。在Go 1.15中,go mod tidy会保留这些依赖,但在Go 1.16中,go mod tidy可能会把这些依赖移除掉,导致某些构建模式下编译失败。

解决方案:在go.mod中,对这些需要保留的依赖,加上// indirect注释,或者用go mod edit -require来显式指定。另外,在运行go mod tidy的时候,要带上所有的构建标签,比如:

go mod tidy -tags=prod,test

这样,go mod tidy会考虑所有构建标签下的依赖,不会误删。

坑6:测试中的竞态条件

Go 1.15和1.16对运行时做了一些优化,goroutine的调度方式有一些细微的变化。我们的项目中有一些测试,之前因为调度的巧合,一直没有问题,但升级之后,竞态条件被触发了,测试偶尔会失败。

我们用go test -race跑了一遍,发现了几处竞态条件,都是因为在goroutine中访问了共享变量,但没有加锁。之前因为调度的原因,这些竞态条件没有被触发,但升级之后,调度方式变了,就被触发了。

解决方案:修复代码中的竞态条件,对共享变量的访问加上互斥锁,或者用channel来通信。修复之后,测试就稳定了。

这个坑也提醒我们,竞态条件是很隐蔽的,可能在某些环境下不出现,但在另一些环境下就会出现。平时要养成用-race跑测试的习惯,尽早发现竞态条件。

四、升级的建议和最佳实践

说了这么多坑,最后分享一些Go版本升级的建议和最佳实践。

1. 仔细阅读发布说明

每次升级之前,一定要仔细阅读官方的发布说明(Release Notes),了解新版本的变化、新特性、不兼容的地方。Go的发布说明写得很详细,会列出所有的重要变化和可能的兼容性问题。提前了解这些,可以避免很多坑。

2. 先在测试环境升级

不要一上来就升级生产环境,先在测试环境或者开发环境升级,跑一遍测试,确保没有问题。可以先升级一个小的、不那么关键的服务,积累经验,然后再升级其他服务。

3. 充分的测试

升级之后,要做充分的测试,包括单元测试、集成测试、性能测试等。特别是要跑一下竞态检测(go test -race),因为新版本的调度变化可能会触发之前隐藏的竞态条件。还要做一下压力测试,确保性能没有下降。

4. 逐步升级

如果项目的版本比较老(比如Go 1.12甚至更早),不要直接升级到最新版本,可以逐步升级,比如先升到1.13,再升到1.14,再升到1.15。这样,每次升级的变化比较小,更容易定位问题。

5. 保持依赖的更新

很多升级的问题,都是因为依赖的库太老了,和新版本的Go不兼容。平时要保持依赖的更新,定期升级依赖的版本,这样在升级Go版本的时候,遇到的兼容性问题会少很多。

6. 做好回滚准备

升级之前,要做好回滚的准备,比如保留旧版本的二进制文件,确保可以快速回滚。如果升级之后发现了严重的问题,可以快速回滚到旧版本,减少影响。

五、写在最后

Go 1.15和1.16是两个很不错的版本,在性能、工具链、标准库等方面都有很多改进。升级之后,我们的服务在编译速度、运行性能、内存使用等方面都有一定的提升,新特性(比如embed)也给开发带来了便利。

当然,升级的过程中也踩了不少坑,花了一些时间来解决。但总体来说,升级是值得的。Go团队在保持向后兼容性方面做得很好,大部分代码都可以无缝升级,只有少数特殊情况需要调整。

如果你还在使用老版本的Go,建议考虑升级到新版本。升级之前做好充分的准备和测试,应该不会有太大的问题。

最后,希望我的这些踩坑经验能对你有所帮助。如果有什么问题或者不同的看法,欢迎在评论区交流。