一次排查panic的心酸历程

280 阅读4分钟

🐉大家好,我是gopher_looklook,现任某独角兽企业Go语言工程师,喜欢钻研Go源码,发掘各项技术在大型Go微服务项目中的最佳实践,期待与各位小伙伴多多交流,共同进步!

起因

前段时间,我们刚上线了几个新的接口到生产环境。其中有一个新接口,上线后在告警群偶尔会弹出一个panic。由于每次弹出的频率都不高,经常被其他接口的报错给刷过去,所以一直没来得及处理。但是一连几天,这个接口都时不时会报panic,博主用内部请求工具,将报错的的请求重新运行,又总能正常返回响应。没办法,博主只好通过TraceID,在日志里找到报错行数,开始逐步分析这个接口偶发panic的原因。

代码

  • 之前的代码逻辑抽象
package main

import (
    "fmt"
    "sync"
)

type Getter struct{}

type User struct {
    UserID    int64
    UserBasic *UserBasic
}

type UserBasic struct {
    UserContact *UserContact
    UserPay     *UserPay
}

type UserContact struct {
    FirstName   string
    LastName    string
    PhoneNumber string
}

type UserPay struct {
    Currency      string
    TotalPayPrice int64
}

func (u *Getter) getUserBasic(user *User) error {
    contact := getUserContactFromRpc() // 模拟一次RPC调用
    if user.UserBasic == nil {
       user.UserBasic = &UserBasic{}
    }
    user.UserBasic.UserContact = contact
    return nil
}

func (u *Getter) getUserPayment(user *User) error {
    payment := getUserPaymentFromRpc() // 模拟一次RPC调用
    if user.UserBasic == nil {
       user.UserBasic = &UserBasic{}
    }
    user.UserBasic.UserPay = payment
    return nil
}

func main() {
    user := &User{UserID: 1000}
    getter := &Getter{}

    var wg sync.WaitGroup
    wg.Add(2) // 添加两个任务

    // 并发执行getUserBasic
    go func() {
       defer wg.Done()
       if err := getter.getUserBasic(user); err != nil {
          fmt.Println("Error getting user basic:", err)
       }
    }()

    // 并发执行getUserPayment
    go func() {
       defer wg.Done()
       if err := getter.getUserPayment(user); err != nil {
          fmt.Println("Error getting user payment:", err)
       }
    }()

    // 等待所有任务完成
    wg.Wait()

    userBasic := user.UserBasic
    // 上报支付数据到用户中心
    uploadDataToUserCenter(user.UserID, userBasic.UserPay.TotalPayPrice) // ==> 发生了panic,报错信息:invalid memory address or nil pointer dereference

}

分析

上述代码是我对核心报错代码的逻辑抽象。panic发生在运行下面这行代码的时候,报错信息是经典的空指针错误: invalid memory address or nil pointer dereference

uploadDataToUserCenter(user.UserID, userBasic.UserPay.TotalPayPrice)

在这行代码中,唯一有可能产生空指针问题的,就是userBasic.UserPay.TotalPayPrice了。根据报错信息,推测应该是userBasic.UserPay为nil,导致了userBasic.UserPay.TotalPayPrice触发了空指针报错。于是博主顺着代码,找到了给userBasic.UserPay赋值的地方。

如果仔细研究getUserPayment函数,可以发现只要不是函数报错导致提前退出或者从RPC接口获取的payment为nil,那么userBasic.UserPay.TotalPayPrice一定是不为nil的。但是博主通过日志观察了好一会儿,认定getUserPayment函数一定顺利获取到了正常的payment数据并正常退出了,所以开始的时候对这个问题一直摸不着头脑。

稍微转换了心情之后,博主相信计算机一定是不会骗人的,一定是有什么点被博主漏掉了。于是博主找到了getUserPayment函数执行的地方,发现它在某些特定条件下,会和另一个函数getUserBasic一起并发执行。并且这个函数也操作到了user.UserBasic的内部数据。乍看之下,这两个函数并发执行并没有问题,在确保user.UserBasic不为空的情况下,它们各自只对感兴趣的字段赋值。getUserBasic也没有操作到UserPay。然而这就是问题所在,如果getUserBasic函数只正常对UserContact赋值,那么此时的UserPay不就是nil了吗?

大胆假设,小心求证。 ——胡适

想象这么一个极端情况,协程1和协程2分别执行了getUserBasicgetUserPayment函数。当协程2 getUserPayment函数走到了user.UserBasic==nil的判断但并未给UserBasic初始化时,协程1 getUserBasic函数也通过了user.UserBasic==nil的判断。之后,getUserPaymentuser.UserBasic初始化并给UserPay赋值;而此时协程1 getUserBasic才给user.UserBasic初始化,这时候初始化的user.UserBasicUserPay字段也就一直为nil了。

  • 数据流转流程图如下

image.png

解决办法

  • 找到了原因,解决问题自然不难,加个互斥锁,确保同一时刻只能有一个协程操作 user, 防止数据相互覆盖和影响就可以了,修改后的代码如下:
package main

import (
    "fmt"
    "sync"
)

type Getter struct{
    mu sync.Mutex // 新增互斥锁,用于保护数据操作
}

type User struct {
    UserID    int64
    UserBasic *UserBasic
}

type UserBasic struct {
    UserContact *UserContact
    UserPay     *UserPay
}

type UserContact struct {
    FirstName   string
    LastName    string
    PhoneNumber string
}

type UserPay struct {
    Currency      string
    TotalPayPrice int64
}

func (u *Getter) getUserBasic(user *User) error {
    contact := getUserContactFromRpc() // 模拟一次RPC调用
    
    u.mu.Lock() // 加互斥锁,确保只有一个协程可以操作user数据
    defer u.mu.Unlock()
    if user.UserBasic == nil {
       user.UserBasic = &UserBasic{}
    }
    user.UserBasic.UserContact = contact
    return nil
}

func (u *Getter) getUserPayment(user *User) error {
    payment := getUserPaymentFromRpc() // 模拟一次RPC调用
    
    u.mu.Lock() // 加互斥锁,确保只有一个协程可以操作user数据
    defer u.mu.Unlock()
    if user.UserBasic == nil {
       user.UserBasic = &UserBasic{}
    }
    user.UserBasic.UserPay = payment
    return nil
}

func main() {
    user := &User{UserID: 1000}
    getter := &Getter{}

    var wg sync.WaitGroup
    wg.Add(2) // 添加两个任务

    go func() {
       defer wg.Done()
       if err := getter.getUserBasic(user); err != nil {
          fmt.Println("Error getting user basic:", err)
       }
    }()

    go func() {
       defer wg.Done()
       if err := getter.getUserPayment(user); err != nil {
          fmt.Println("Error getting user payment:", err)
       }
    }()

    // 等待所有任务完成
    wg.Wait()

    userBasic := user.UserBasic
    // 上报支付数据到用户中心
    uploadDataToUserCenter(user.UserID, userBasic.UserPay.TotalPayPrice) // ==> 发生了panic,报错信息:invalid memory address or nil pointer dereference

}

总结

本文记录了博主排查生产环境panic问题的一次心酸历程。如果你也有过类似的经历,欢迎在评论区留言和探讨!