上周三晚上十点,我正准备睡觉,手机突然响了。是运维同事打来的,说线上服务出问题了,内存占用持续飙升,已经触发了告警,让我赶紧看看。

我打开电脑,连上监控系统,看到服务的内存曲线像一条直线一样往上走,从正常的500MB涨到了2GB,而且还在继续涨。如果不处理,再过半小时服务就会OOM(内存溢出)崩溃。

我深吸一口气,开始了一夜的排查。这篇文章,就来记录这次线上Bug的排查全过程,以及最终发现的Go 1.22版本的一个坑。

问题现象

先说说问题现象。

我们的服务是一个Go语言写的HTTP API服务,部署在K8s集群上,有10个Pod。平时运行很稳定,内存占用大约500MB,CPU占用大约20%。

那天晚上八点左右,运维发布了一个新版本。发布之后,服务一开始运行正常,但大约一个小时后,内存开始缓慢上升。到十点的时候,内存已经涨到了2GB,而且还在继续涨。

奇怪的是,CPU占用并没有明显变化,还是20%左右。请求量也没有明显增加,响应时间也正常。只有内存,在持续缓慢地上涨。

这种现象,典型的内存泄漏。

我第一反应是,新版本的代码有内存泄漏。但我们发布前做了代码审查,也跑了测试,不应该有这么明显的内存泄漏。

而且,这次发布的改动很小,只是升级了Go版本,从Go 1.21升级到了Go 1.22,另外改了几个小bug。代码逻辑没有大的变化。

"难道是Go 1.22本身有问题?"我心里闪过一个念头,但很快否定了。Go语言的稳定性一直很好,不太可能有这么明显的内存泄漏。

但后来的事实证明,我的第一反应是错的。这个Bug,确实和Go 1.22有关。

初步排查

我开始了初步排查。

第一步,看日志。我翻了服务的日志,没有发现明显的错误,也没有panic。服务运行正常,只是内存一直在涨。

第二步,看监控。我看了各个Pod的内存情况,发现10个Pod的内存都在涨,而且涨幅差不多。这说明不是某个Pod的问题,而是服务本身的问题。

第三步,看发布记录。我确认了这次发布的改动:升级Go 1.21到1.22,改了三个小bug,没有其他大的改动。

第四步,回滚。因为内存还在持续上涨,为了避免服务崩溃,我先做了回滚,把服务回滚到了上一个版本(Go 1.21)。回滚之后,内存很快就降回了正常的500MB,而且不再上涨。

这就确认了,问题确实是这次发布引入的。但具体是Go 1.22的问题,还是那三个小bug的问题,还需要进一步排查。

因为回滚之后服务恢复了正常,我有了时间来慢慢排查。

深入分析

我开始深入分析这个问题。

首先,我把那三个小bug的改动看了一遍,都是很简单的修改,不可能导致内存泄漏。所以,问题大概率出在Go 1.22的升级上。

但Go 1.22到底有什么变化,会导致内存泄漏呢?

我去查了Go 1.22的release notes,看看有哪些重要的变化。

Go 1.22的主要变化有:

  • 循环变量的语义改变了(for循环变量不再共享)
  • 增强了随机数生成
  • 改进了内存分配器
  • 改进了垃圾回收器
  • 新增了一些标准库功能

其中,"循环变量的语义改变"这个变化,引起了我的注意。

在Go 1.22之前,for循环的变量是在整个循环中共享的。比如:

for i := 0; i < 10; i++ {
    go func() {
        fmt.Println(i)
    }()
}

这段代码,在Go 1.22之前,输出的可能都是10,因为所有的goroutine共享同一个i变量,等goroutine执行的时候,i已经变成10了。

而在Go 1.22之后,每次循环都会创建一个新的i变量,所以输出的是0到9的随机顺序。

这个变化,是为了解决Go语言长期以来的一个"坑"。但这个变化,怎么会导致内存泄漏呢?

我想了很久,没有想通。于是,我决定用pprof来分析内存。

pprof分析

Go语言有一个非常强大的工具,叫pprof,可以用来分析CPU和内存的使用情况。

我在测试环境部署了Go 1.22版本的服务,然后用pprof抓取了内存快照。

pprof的结果显示,内存主要被一个map占用了。这个map,是我们用来缓存用户信息的,key是用户ID,value是用户信息结构体。

正常情况下,这个map的大小应该是有限的,因为我们有缓存过期机制,过期的用户信息会被删除。

但pprof显示,这个map的大小一直在增长,而且里面的条目从来没有被删除。

"难道是缓存过期机制出了问题?"我心里想。

我去看了缓存过期的代码。我们的缓存过期机制,是用一个goroutine定期扫描map,删除过期的条目。代码大概是这样的:

func (c *Cache) startCleanup() {
    ticker := time.NewTicker(5 * time.Minute)
    go func() {
        for range ticker.C {
            c.mu.Lock()
            for key, item := range c.items {
                if item.expired() {
                    delete(c.items, key)
                }
            }
            c.mu.Unlock()
        }
    }()
}

这段代码,看起来没什么问题。定期扫描map,删除过期的条目。

但等等,我突然想到了什么。

在Go 1.22之前,for range循环的key和value变量是共享的。而在Go 1.22之后,每次循环都会创建新的变量。

但这和内存泄漏有什么关系呢?

我又仔细看了一遍代码,突然发现了问题所在。

在我们的代码中,有一个地方,是这样用的:

for key, item := range c.items {
    if item.expired() {
        delete(c.items, key)
    } else {
        // 把item的指针存到另一个地方
        c.anotherSlice = append(c.anotherSlice, &item)
    }
}

这段代码的意图是,遍历map,如果条目过期了就删除,如果没过期,就把条目的指针存到另一个slice里。

在Go 1.22之前,因为item变量是共享的,所以&item取到的是同一个地址。每次循环,item的值会被覆盖,但地址不变。所以,anotherSlice里存的都是同一个地址,指向最后一个item的值。

这其实是一个bug,但因为anotherSlice里存的都是同一个指针,所以不会导致内存泄漏(因为只有一个item实例)。

但在Go 1.22之后,每次循环都会创建一个新的item变量,所以&item取到的是不同的地址。这样,anotherSlice里就存了很多不同的指针,每个指针都指向一个独立的item实例。

而anotherSlice是一个全局变量,从来没有被清理过。所以,每次缓存清理的时候,都会把未过期的item指针追加到anotherSlice里,导致anotherSlice越来越大,内存也越来越大。

这就是内存泄漏的根本原因!

问题确认

找到问题之后,我写了一个简单的测试程序来验证。

package main

import (
    "fmt"
    "time"
)

type Item struct {
    value int
}

func main() {
    items := make(map[int]*Item)
    for i := 0; i < 100; i++ {
        items[i] = &Item{value: i}
    }

    var anotherSlice []*Item

    for key, item := range items {
        if key%2 == 0 {
            delete(items, key)
        } else {
            anotherSlice = append(anotherSlice, &item)
        }
    }

    fmt.Printf("anotherSlice length: %d\n", len(anotherSlice))
    for i, ptr := range anotherSlice {
        fmt.Printf("index %d, value %d, address %p\n", i, ptr.value, ptr)
    }

    time.Sleep(time.Second)
}

在Go 1.21上运行,anotherSlice里的所有指针都指向同一个地址,value都是最后一个item的值。

在Go 1.22上运行,anotherSlice里的每个指针都指向不同的地址,value各不相同。

这就确认了我的分析。Go 1.22的循环变量语义改变,导致了我们代码中一个隐藏的bug被触发,造成了内存泄漏。

修复问题

找到问题之后,修复就很简单了。

原来的代码:

for key, item := range c.items {
    if item.expired() {
        delete(c.items, key)
    } else {
        c.anotherSlice = append(c.anotherSlice, &item)
    }
}

修复后的代码:

for key, item := range c.items {
    if item.expired() {
        delete(c.items, key)
    } else {
        // 创建一个副本,而不是取循环变量的地址
        itemCopy := item
        c.anotherSlice = append(c.anotherSlice, &itemCopy)
    }
}

或者,更简单的方式,直接存值而不是指针:

for key, item := range c.items {
    if item.expired() {
        delete(c.items, key)
    } else {
        c.anotherSlice = append(c.anotherSlice, item)
    }
}

我选择了第二种方式,直接存值,因为item结构体不大,存值更安全,也避免了指针的问题。

修复之后,我在测试环境部署了新版本,观察了几个小时,内存不再上涨了,稳定在500MB左右。

然后,我在凌晨四点,把修复后的版本发布到了线上。发布之后,观察了一个小时,内存正常,服务稳定。

这时候,天已经快亮了。我揉了揉发酸的眼睛,长长地舒了一口气。

经验和教训

这次排查,花了我整整一夜的时间。虽然过程很辛苦,但也让我学到了很多东西。

第一个教训,是要重视语言版本的升级。

很多人觉得,语言版本的小升级(比如从1.21到1.22)不会有什么问题,直接升就好了。但实际上,即使是小版本升级,也可能有breaking change,可能会触发隐藏的bug。

Go 1.22的循环变量语义改变,就是一个典型的例子。这个改变,在大多数情况下是好的,解决了长期以来的一个坑。但如果你的代码里有依赖旧语义的地方(哪怕是bug),升级之后就可能出问题。

所以,语言版本升级之后,一定要做充分的测试,特别是长时间运行的稳定性测试。

第二个教训,是要理解语言的底层语义。

作为Go开发者,应该理解Go语言的底层语义,比如循环变量的作用域、闭包的变量捕获、slice和map的内部实现等。只有理解了这些,才能写出正确的代码,也才能在出问题的时候快速定位。

第三个教训,是要善用工具。

Go语言的pprof是一个非常强大的工具,在排查内存泄漏、CPU性能问题时非常有用。遇到线上问题,不要瞎猜,要用工具来分析,用数据说话。

第四个教训,是代码审查要仔细。

这个bug,其实在代码审查的时候应该能发现。在循环中取循环变量的地址,本身就是一个危险的操作。如果代码审查的时候仔细一点,可能就不会把这个bug留到线上。

第五个教训,是要有完善的监控和告警。

这次问题,是监控系统先发现的,告警通知了我们。如果没有监控和告警,等服务OOM崩溃了才发现,影响会更大。所以,完善的监控和告警,是线上服务稳定运行的保障。

Go 1.22循环变量变化的影响

最后,再详细说说Go 1.22循环变量语义变化的影响。

这个变化,是Go语言团队考虑了很久才做的。因为旧的语义(循环变量共享)导致了很多bug,特别是在goroutine中使用循环变量的时候。

比如,下面这段代码,在Go 1.22之前是有bug的:

for _, user := range users {
    go func() {
        user.DoSomething()
    }()
}

所有的goroutine可能都操作最后一个user。这是Go初学者最常踩的坑之一。

Go 1.22之后,每次循环创建新的变量,这个问题就自然解决了。

但是,这个变化也可能导致一些旧代码出问题。比如我们这次遇到的,在循环中取循环变量的地址。

在Go 1.22之前,&item是同一个地址;在Go 1.22之后,&item是不同的地址。如果你的代码依赖旧的语义(哪怕是无意的),升级之后就可能出问题。

所以,在升级到Go 1.22之后,建议检查一下代码中是否有以下模式:

  • 在循环中取循环变量的地址(&item)
  • 在循环中把循环变量的指针存到集合中
  • 在goroutine中使用循环变量(这个在新版本中是修复了,但要确认行为是否符合预期)

如果有这些模式,要仔细检查,确保升级后的行为是正确的。

写在最后

这次线上Bug的排查,从晚上十点到凌晨四点,整整六个小时。虽然很辛苦,但最终找到了问题,修复了bug,也学到了很多东西。

技术工作就是这样,总会遇到各种各样的问题。重要的是,遇到问题不要慌,要有条理地排查,用工具和数据说话,找到根本原因,然后彻底修复。

同时,也要从每次问题中总结经验教训,避免以后再犯同样的错误。

希望这篇文章,能帮你了解Go 1.22循环变量变化可能带来的影响,也能在你遇到类似问题的时候,提供一些排查思路。

最后,愿大家的线上服务永远稳定,没有Bug。