我们项目最近从Go 1.20升级到了1.21,本以为是个小版本升级,结果踩了不少坑。有些是新特性带来的,有些是兼容性问题,还有些是性能方面的。

这篇文章总结一下升级过程中遇到的问题和解决方案,如果你也在考虑升级Go 1.21,希望能帮你少走点弯路。

新特性的坑

Go 1.21引入了不少新特性,用起来很爽,但也有一些坑。

第一个坑是slices包。Go 1.21在标准库里加了slices包,提供了排序、查找、反转等常用操作。用起来很方便,但有个问题:slices.Sort是不稳定排序,如果需要稳定排序要用slices.SortStableFunc。我们项目里有个地方依赖了排序的稳定性,升级之后用了slices.Sort,结果出了bug,排查了半天才发现是排序不稳定导致的。

第二个坑是maps包。maps包提供了遍历、克隆、相等判断等操作。但maps.Equal的判断逻辑和你想的可能不一样,它只比较键值对是否相等,不比较map的顺序(因为map本来就没有顺序)。还有maps.Clone是浅拷贝,如果value是指针或切片,克隆后的map和原map共享底层数据。我们有个地方用了maps.Clone之后修改了value,结果原map也变了,排查了很久。

第三个坑是新的内置函数。Go 1.21加了min、max、clear三个内置函数。min和max很方便,但要注意它们的参数类型必须一致,而且至少要有一个参数。clear函数可以清空map或切片,但clear切片只是把元素清零,长度不变,不是把长度变成0。我们有个同事用clear清空切片,以为长度变成0了,结果后面append的时候出了问题。

第四个坑是log/slog包。这是Go 1.21新加的结构化日志包,功能很强。但slog的默认日志级别是Info,Debug级别的日志不会输出。而且slog的性能和zap比还有差距,如果对日志性能要求很高,可能还是要用zap。我们项目试了slog,发现高并发下日志写入有瓶颈,最后还是换回了zap。

兼容性问题

Go的向后兼容性一直做得很好,但1.21还是有一些需要注意的地方。

第一个问题是go.mod的版本要求。升级到Go 1.21之后,go.mod里的go指令要改成1.21。如果你的项目依赖了一些老的库,这些库的go.mod版本比较低,可能会出现版本冲突。我们项目依赖了一个很久没更新的库,go.mod还是1.16,升级之后go mod tidy报错,最后只能fork了一份自己改。

第二个问题是编译器的行为变化。Go 1.21的编译器对一些非法代码的检测更严格了。比如以前可以写的一些循环变量捕获的代码,现在编译器会给出更明确的警告。还有一些不安全的类型转换,以前能编译过,现在会报错。我们项目里有几处用了unsafe.Pointer做类型转换,升级之后编译不过,只能改成更安全的写法。

第三个问题是测试的变化。Go 1.21的testing包加了一些新功能,比如testing.B的ResetTimer行为有变化。还有一些测试辅助函数的行为和以前不一样了。我们项目里有几个基准测试,升级之后结果差异很大,后来发现是ResetTimer的调用时机变了,调整之后就正常了。

第四个问题是CGO的变化。Go 1.21对CGO的处理有一些调整,特别是在Windows平台上。我们项目在Windows上用CGO调用了一个C库,升级之后编译报错,说是找不到某个头文件。最后发现是CGO的include路径变了,调整了一下环境变量就好了。

性能方面的坑

Go 1.21在性能上有不少优化,但也有一些坑。

第一个坑是GC的变化。Go 1.21对垃圾回收器做了优化,默认的GC pacing策略变了。大部分情况下性能会更好,但在某些特定的内存分配模式下,GC的停顿时间反而变长了。我们有个服务是大内存分配模式,升级之后GC停顿时间从1ms变成了3ms,影响了P99延迟。最后调整了GOGC参数才解决。

第二个坑是goroutine调度的变化。Go 1.21对调度器做了一些优化,goroutine的切换更快了。但在高并发场景下,我们发现有一些goroutine出现了饥饿的情况。排查之后发现是新的调度策略对某些特定的goroutine模式不太友好,最后通过调整GOMAXPROCS和工作队列参数解决了。

第三个坑是defer的性能变化。Go 1.21优化了defer的性能,大部分情况下defer的开销更小了。但在某些热点路径上,我们发现defer的性能反而不如以前。后来发现是因为新的defer实现对某些特定的函数签名有特殊优化,我们的代码刚好不在优化范围内。最后把热点路径上的defer改成了手动调用,性能就回来了。

第四个坑是内存分配器的变化。Go 1.21对内存分配器做了调整,小对象的分配更快了。但大对象的分配策略变了,在某些情况下会出现内存碎片。我们有个服务长时间运行之后内存占用越来越高,排查之后发现是大对象分配导致的内存碎片,最后通过调整对象池的大小解决了。

工具链的变化

Go 1.21的工具链也有不少变化。

第一个变化是Go工具链的自动下载。Go 1.21支持工具链的自动管理,如果go.mod里指定的Go版本比当前安装的版本新,Go会自动下载对应的工具链。这个功能很方便,但在企业环境里可能会有问题,因为网络受限或者安全策略不允许自动下载。我们公司的构建环境是离线的,自动下载功能导致构建失败,最后只能关闭这个功能。

第二个变化是go vet的检查更严格了。Go 1.21的go vet加了一些新的检查规则,比如对time.After的使用警告、对错误处理的检查等。我们项目升级之后go vet报了很多警告,虽然不影响编译,但CI里把vet警告当成错误,只能一个个修。

第三个变化是gofmt的格式变化。Go 1.21的gofmt对一些代码的格式化和以前不一样了,比如泛型代码的缩进、注释的位置等。我们项目升级之后,gofmt检查发现很多文件格式不对,只能批量重新格式化。建议升级之后先跑一遍gofmt,把格式统一了。

第四个变化是pprof的变化。Go 1.21的pprof加了一些新的指标,展示方式也有变化。用惯了老版本pprof的人可能需要适应一下。不过新的pprof功能更强,能看到更多细节,对性能排查很有帮助。

实战经验

踩了这么多坑,也总结了一些实战经验。

第一个经验是升级之前先读release notes。Go的release notes写得很详细,每个新特性、每个变化都有说明。花时间读一遍,能避免很多坑。我们最开始就是没仔细读release notes,升级之后才发现很多变化,走了不少弯路。

第二个经验是先在测试环境验证。不要直接在生产环境升级,先在测试环境跑一段时间,看看有没有性能问题、兼容性问题。我们项目在测试环境跑了两周,发现了好几个问题,修好了才上的生产。

第三个经验是做好回滚准备。升级之前做好备份,准备好回滚方案。万一升级之后出了问题,能快速回滚到旧版本。我们项目就准备了回滚脚本,虽然最后没用上,但有备无患。

第四个经验是关注社区反馈。Go升级之后,社区里会有很多人分享遇到的问题和解决方案。多关注GitHub issue、Reddit、微信群,能提前知道很多坑。我们遇到的几个问题,都是在社区里看到别人讨论才提前准备的。

第五个经验是不要急于升级。新版本刚发布的时候,可能会有一些bug,等几个小版本再升级更稳妥。Go 1.21刚发布的时候我们就升了,遇到了好几个编译器的bug,后来1.21.1修复了。如果不急的话,等1.21.1或1.21.2再升更稳定。

写在最后

Go 1.21是一个很不错的版本,新特性很实用,性能也有提升。但升级的时候还是要小心,特别是大项目,可能会遇到各种各样的问题。

总的来说,升级Go 1.21是值得的。slices、maps、slog这些新包确实能提高开发效率,编译器和运行时的优化也能带来性能提升。只要做好充分的准备,升级过程还是比较顺利的。

Go语言的版本迭代一直很稳健,向后兼容性做得很好,大部分项目升级都不会有大问题。但越是这样,越不能掉以轻心,小问题积累起来也会很麻烦。

希望这篇文章能帮到正在考虑升级Go 1.21的你。如果遇到了文章里没提到的问题,欢迎交流。