最近把公司的几个Go服务从Go 1.13迁移到了Go 1.15/1.16,过程中踩了不少坑。今天记录一下整个迁移过程,包括迁移前的准备、迁移中的问题和解决方案、迁移后的验证和优化,希望能帮到大家。

先说说背景。我们公司有十几个Go微服务,之前一直用的是Go 1.13,运行得比较稳定。但Go 1.13已经比较老了,很多新特性用不了,而且一些新的库和框架已经开始要求更高版本的Go了。另外,Go 1.15和1.16在性能、工具链、标准库等方面都有很多改进,升级之后能享受到这些好处。

所以,我们决定把所有的Go服务都升级到Go 1.16(写这篇文章的时候Go 1.16还没正式发布,我们用的是1.16的beta版,正式版发布之后直接切到正式版)。整个迁移过程花了大概两周的时间,从准备、测试、灰度到全量上线,中间踩了不少坑,也积累了一些经验。

今天就把整个迁移过程记录下来,分享给大家。

一、迁移前的准备

迁移之前,一定要做好充分的准备,不要上来就直接改版本、直接上线,那样很容易出问题。我们在迁移之前,做了以下几个方面的准备。

1. 阅读发布说明,了解变化

这是最基本的,也是最重要的。在迁移之前,一定要仔细阅读Go 1.15和Go 1.16的发布说明(Release Notes),了解新版本有哪些变化,哪些是不兼容的变化,哪些是需要注意的。

Go的发布说明写得很详细,会列出所有的重要变化,包括语言变化、工具链变化、标准库变化、性能变化等,还会列出不兼容的变化和需要注意的事项。仔细阅读发布说明,能让你提前知道哪些地方可能会出问题,提前做好准备。

我们在迁移之前,花了一天的时间,仔细阅读了Go 1.14、1.15、1.16的发布说明(因为我们是从1.13直接升到1.16,所以要把中间版本的变化都看一遍),把可能影响我们的变化都列了出来,然后逐个评估影响,制定应对方案。

比如,Go 1.14中,defer的性能提升了,但也有一些行为变化;Go 1.15中,链接器重写了,一些cgo项目可能会有链接错误;Go 1.16中,go命令默认是模块感知的,go get的行为变了,embed包是新的,等等。这些变化,我们都提前了解了,提前做好了准备。

2. 评估依赖的兼容性

升级Go版本,不仅要考虑自己的代码,还要考虑依赖的第三方库的兼容性。有些第三方库,可能在新版本的Go下有问题,或者需要升级到新版本才能兼容。

我们的做法是,把所有服务的go.mod文件都拿出来,列出所有的依赖,然后逐个检查这些依赖在Go 1.16下是否兼容,是否需要升级。对于那些已经不维护了、或者在新版本Go下有问题的依赖,我们提前找了替代方案,或者自己fork了一份来修复。

比如,我们有一个服务用了一个比较老的ORM库,这个库已经不维护了,在Go 1.16下有一些问题。我们评估了一下,决定换成一个更主流的ORM库,虽然花了一些时间,但从长远来看是值得的。

3. 准备测试环境

迁移之前,要准备好测试环境,包括Go 1.16的开发环境、测试环境、CI/CD环境等。确保在测试环境中,可以用Go 1.16编译和运行服务,并且可以做完整的测试。

我们的做法是,先在CI/CD系统中添加了Go 1.16的支持,这样每次提交代码,都会用Go 1.16编译和测试一遍。然后,我们在测试环境部署了用Go 1.16编译的服务,做完整的功能测试、性能测试、压力测试。

4. 制定回滚方案

迁移之前,一定要制定好回滚方案。如果迁移之后出现了严重的问题,要能快速回滚到旧版本,减少影响。

我们的回滚方案很简单:保留旧版本的编译产物和镜像,如果新版本出现问题,直接切回旧版本的镜像即可。因为我们的服务是无状态的,回滚很简单,不会有数据一致性的问题。如果是有状态的服务,回滚方案就要复杂一些,需要考虑数据的兼容性。

二、迁移中的问题和解决方案

准备工作做好之后,我们就开始了迁移。在迁移过程中,遇到了不少问题,这里记录一下主要的几个问题和解决方案。

问题1:go get的行为变化

Go 1.16中,go get的行为发生了变化。在Go 1.16之前,go get既可以用来下载和安装包,也可以用来修改go.mod中的依赖。但在Go 1.16中,go get只用来修改go.mod中的依赖,不再用来安装包了。安装包要用go install。

这个变化,对我们的影响主要是在CI/CD脚本和开发文档中。我们的CI脚本里,有一些go get命令用来安装工具,比如go get github.com/golang/mock/mockgen,这些命令在Go 1.16下行为变了,会修改go.mod,而不是安装工具。

解决方案:把CI脚本和开发文档中的go get命令改成go install,并指定版本。比如,把go get github.com/golang/mock/mockgen改成go install github.com/golang/mock/mockgen@v1.4.4。这样,工具会被安装到GOPATH/bin目录下,不会修改go.mod。

另外,Go 1.16中,GO111MODULE的默认值变成了on,也就是说,默认就是模块模式,不需要再设置GO111MODULE=on了。我们的脚本里有一些设置GO111MODULE的地方,虽然不影响,但可以清理掉了。

问题2:cgo库的链接错误

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

这个问题,是因为Go 1.15重写了链接器,新的链接器对cgo的处理方式有变化,一些特殊的符号解析方式不支持了。

解决方案:我们试了几个方法,最后是通过升级cgo库的版本解决的。那个cgo库的新版本,已经适配了Go 1.15的新链接器,升级之后就没问题了。

如果你的cgo库没有新版本,或者升级之后还是有问题,可以试试在go build的时候加上-ldflags="-linkmode=external",强制使用外部链接器(系统的gcc),而不是Go自带的内部链接器。这样,链接的工作交给系统的gcc来做,兼容性会好一些。不过,使用外部链接器会增加链接时间,而且需要系统安装了gcc。

问题3:embed的使用

Go 1.16新增了embed包,可以把静态文件内嵌到二进制文件中。我们有一个服务,有一些静态的配置文件和模板文件,以前是和二进制文件一起部署的,部署的时候要注意文件路径,比较麻烦。我们想利用embed这个新特性,把这些文件内嵌到二进制文件中,简化部署。

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

但在使用的过程中,我们遇到了几个小问题:

第一个问题是,//go:embed指令必须紧跟在变量声明的前面,中间不能有空行或者其他注释。一开始我们写的时候,在指令和变量之间加了一个空行,结果embed不生效,找了半天才发现是这个问题。

第二个问题是,embed的路径是相对于源文件的,不是相对于项目根目录的。一开始我们以为是相对于项目根目录的,结果路径写错了,文件找不到。后来看了文档才知道,是相对于源文件的,改了路径之后就好了。

第三个问题是,embed的文件是只读的,不能修改。我们有一个配置文件,以前是可以在运行时修改的,用了embed之后,就不能修改了。后来我们的解决方案是,把默认配置内嵌进去,运行的时候先读内嵌的默认配置,如果外部有配置文件,就覆盖默认配置。这样,既简化了部署,又保留了配置的灵活性。

总的来说,embed是一个很好用的新特性,能简化部署,推荐大家使用。但在使用的时候,要注意上面说的几个小问题。

问题4:标准库的行为变化

Go 1.14、1.15、1.16对标准库做了很多改进,大部分是兼容的,但也有一些行为变化,可能会影响现有的代码。

我们遇到的主要有几个:

第一个是crypto/tls的变化。Go 1.15中,crypto/tls默认拒绝了一些不安全的TLS配置,比如小于2048位的RSA证书。我们有一个服务,需要调用一个老的内部接口,那个接口用的是1024位的自签名证书,在Go 1.13下可以正常调用,但升级到Go 1.15之后,就报错了,说证书不安全。

解决方案:联系那个接口的维护方,升级了证书,换成了2048位的RSA证书。升级之后就没问题了。如果暂时无法升级证书,可以在代码中临时放宽TLS验证,但不推荐这样做,因为不安全。

第二个是encoding/json的变化。Go的新版本对encoding/json做了一些优化和bug修复,大部分是兼容的,但有一些边界情况的行为变了。我们有一个服务,对JSON的解析有一些特殊的处理,升级之后,有一个边界情况的行为变了,导致一个测试用例失败了。

解决方案:修改了代码,适配了新的行为。这个问题不大,只是一个边界情况,影响很小。但这也提醒我们,升级版本之后,一定要跑完整的测试,确保所有的行为都符合预期。

第三个是os/exec的变化。Go 1.16中,os/exec包的行为有一些变化,比如对环境变量的处理、对路径的查找等。我们有一个服务,用os/exec调用外部命令,升级之后,有一个命令的路径查找出了问题。

解决方案:修改了代码,用绝对路径调用外部命令,而不是依赖PATH查找。这样更可靠,也避免了环境变量的影响。

问题5:性能变化

升级版本之后,性能可能会有变化,大部分情况下是提升,但也有可能在某些场景下下降。我们在迁移之后,做了性能测试,发现大部分服务的性能都有提升,比如编译速度更快了,GC的暂停时间更短了,内存使用也有所减少。

但也有一个服务,性能略有下降。这个服务有大量的小对象分配,在Go 1.16下,内存分配的开销略有增加,导致QPS下降了大约5%。

解决方案:我们对这个服务做了一些优化,比如使用sync.Pool复用对象,减少小对象的分配;优化数据结构,减少内存分配。优化之后,性能不仅恢复了,还比升级之前提升了大约10%。

这个问题提醒我们,升级版本之后,一定要做性能测试,确保性能没有下降。如果有下降,要分析原因,做针对性的优化。

三、迁移后的验证和优化

迁移完成之后,不要以为就完事了,还要做充分的验证和优化,确保服务在新版本下运行稳定、性能良好。

1. 完整的测试

迁移之后,一定要跑完整的测试,包括单元测试、集成测试、端到端测试。确保所有的功能都正常,没有因为版本升级而引入新的bug。

我们的做法是,在CI/CD中,用Go 1.16跑所有的测试,确保测试全部通过。然后,在测试环境做完整的功能测试,模拟真实的业务场景,确保所有功能都正常。最后,做压力测试和性能测试,确保性能达标。

2. 灰度发布

测试通过之后,不要直接全量上线,要先灰度发布,让一小部分流量先切到新版本,观察一段时间,确保没有问题之后,再逐步扩大流量,最后全量上线。

我们的做法是,先在测试环境运行一周,确保稳定。然后,在生产环境选一个服务,先切10%的流量到新版本,观察24小时,看错误率、延迟、资源使用等指标是否正常。如果没问题,再切到50%,观察24小时,最后全量。一个服务全量之后,观察一周,没问题了,再迁移下一个服务。

这样,即使新版本有问题,影响也很小,而且能及时发现和回滚。

3. 监控和告警

迁移之后,要加强监控,关注服务的运行状态,包括错误率、延迟、QPS、CPU使用率、内存使用率、GC情况等。如果发现异常,要及时告警,及时排查。

我们的做法是,在监控系统中,给每个服务都加了详细的监控指标,设置了告警阈值。迁移之后的前两周,我们每天都会看监控数据,确保服务运行正常。如果发现异常,立刻排查,及时解决。

4. 利用新特性优化代码

升级到新版本之后,可以利用新版本的新特性,优化代码,提升性能和开发效率。

比如,Go 1.16的embed包,可以把静态文件内嵌到二进制中,简化部署;Go 1.15的改进的链接器,编译速度更快;Go 1.14的改进的defer,defer的性能提升了很多,可以放心使用;还有一些标准库的新API,可以简化代码。

我们在迁移之后,对几个服务做了优化,用了一些新特性,比如embed内嵌静态文件、用新的API简化代码、用sync.Pool优化内存分配等。优化之后,代码更简洁了,性能也有提升。

四、一些经验和建议

最后,分享一些Go版本迁移的经验和建议。

1. 不要跨太多版本

如果你的Go版本比较老,比如Go 1.10甚至更早,不要直接升到最新版本,建议逐步升级,比如先升到1.12,再升到1.14,再升到1.16。这样,每次升级的变化比较小,更容易定位问题,也更容易回滚。

我们是从1.13升到1.16,跨了3个版本,虽然也顺利完成了,但中间还是遇到了不少问题。如果是从更老的版本升,问题可能会更多。

2. 仔细阅读发布说明

这一点再怎么强调都不为过。迁移之前,一定要仔细阅读发布说明,了解所有的变化,尤其是不兼容的变化和需要注意的事项。很多问题,如果你提前看了发布说明,就能提前避免,不用等到出了问题再排查。

3. 充分测试,灰度发布

迁移之后,一定要做充分的测试,确保功能正常、性能达标。然后,灰度发布,小流量验证,没问题了再全量。不要上来就全量上线,那样风险太大了,出了问题影响也大。

4. 做好回滚准备

迁移之前,一定要制定好回滚方案,确保出了问题能快速回滚。保留旧版本的编译产物和镜像,确保可以随时切回去。对于有状态的服务,还要考虑数据的兼容性,确保回滚之后数据不会出问题。

5. 关注社区和issue

如果在迁移过程中遇到了问题,可以去GitHub上搜一下,看看是不是已知的问题,有没有解决方案。Go的社区很活跃,很多问题都有人遇到过,也有解决方案。如果是新的问题,也可以提issue,或者在社区里求助。

6. 不要为了升级而升级

最后,不要为了升级而升级。如果你的服务运行得很稳定,没有用到新特性的需求,也没有遇到老版本的问题,那不一定非要升级到最新版本。升级是有成本的,需要时间、需要测试、需要承担风险。如果升级带来的好处不大,那可以暂时不升级,等有需要的时候再升。

当然,也不要一直用很老的版本,太老的版本会有安全漏洞,而且新的库和框架可能不再支持。一般来说,用最近的两个稳定版本就可以了,不需要追最新,但也不要太落后。

五、写在最后

Go 1.15/1.16的迁移,虽然过程中踩了不少坑,但整体来说还是比较顺利的。迁移完成之后,我们的服务在编译速度、运行性能、内存使用等方面都有一定的提升,也用上了一些新特性,代码更简洁了,部署也更方便了。

Go语言的版本升级,一直做得比较好,向后兼容性很强,大部分代码不需要修改就能在新版本下运行。但即使如此,迁移的时候还是要小心谨慎,做好准备,做好测试,做好回滚,确保平稳迁移。

希望我的这些迁移经验,能对正在做Go版本升级的朋友有所帮助。如果有什么问题,欢迎在评论区交流。