🐉大家好,我是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分别执行了getUserBasic和getUserPayment函数。当协程2 getUserPayment函数走到了user.UserBasic==nil的判断但并未给UserBasic初始化时,协程1 getUserBasic函数也通过了user.UserBasic==nil的判断。之后,getUserPayment给user.UserBasic初始化并给UserPay赋值;而此时协程1 getUserBasic才给user.UserBasic初始化,这时候初始化的user.UserBasic的UserPay字段也就一直为nil了。
- 数据流转流程图如下
解决办法
- 找到了原因,解决问题自然不难,加个互斥锁,确保同一时刻只能有一个协程操作 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问题的一次心酸历程。如果你也有过类似的经历,欢迎在评论区留言和探讨!