1、故障爆发
12月1号上午10点出头,我们收到阿里云监控告警:客户官网探测异常,如图所示:

然后我们DBA查看了后端数据库实例,发现数据库连接已经被用尽了,导致服务出现异常,如图所示:

当时我们和客户协商之后决定对数据库会话进行kill处理,如图所示:

经过临时处理之后,应用恢复正常,如图所示:

2、再次出现故障
在14:16分,客户再次联系我们,反馈成有部分接口超时,如图所示:

由于客户的服务底层代码为Go,我们之前建议过把代码里面加入pprof进行debug,否则出现问题我们没有办法看底层的线程池和堆栈信息。在这种情况下,我们尝试通过tcpdump抓取网络包,用来分析go应用到底在干什么。通过wireshark解包后,我们发现了异常情况:go应用和mysql数据库之间进行了大量的TCP Keep-Alive网络包交互,但是没有正常的SQL查询交互,如图所示:

我当时就判断:应该是go应用存在数据库连接池泄露,导致应用的数据库连接池用尽,最终出现如上图的情况,只有TCP Keep-Alive,没有正常的交互SQL。但是因为没有pprof,再加上是业务高峰期,我们无法定位到泄露代码。客户临时重启应用进行修复。
3、业务低谷问题复现
加入pprof之后,我们跟踪了goroutine,根据跟踪的图,我们还是没有办法定位到故障代码,如图所示:
这种情况下,问题似乎走入了死胡同。我想了一下:我们之前网络抓包的时候,应用已经处于假死状态了,导致我们无法看到到底是因为什么SQL导致的,如果我们把应用重启,在服务刚开始的时候就进行网络抓包,应该是能抓到问题SQL。我们再次进行抓包,这次发现了异常SQL:每个连接数据库的TCP连接,最终退出的时候,都执行了开启事务。如图所示:

最终定位到问题代码为以下代码,如图所示:

我们当时建议在判断if err的时候,添加回滚操作。但是客户还是想深入排查为什么会出现此bug。
4、彻底解决bug
我再次分析了网络包,发现出现问题的TCP线程池为101个。而我们在pprof跟踪的图上面刚刚好看到对应数量的goroutine。如图所示:

我们发现很多人也遇到这种问题,而点赞最高的回答,如下所示:
您要确保Begin()、Commit()和Rollback()出现在同一个函数中。它使交易更容易跟踪,并让您确保它们通过使用defer.
这是一个这样的例子,它根据是否返回错误来执行 Commit 或 Rollback:
func (s Service) DoSomething() (err error) {
tx, err := s.db.Begin()
if err != nil {
return
}
defer func() {
if err != nil {
tx.Rollback()
return
}
err = tx.Commit()
}()
if _, err = tx.Exec(...); err != nil {
return
}
if _, err = tx.Exec(...); err != nil {
return
}
// ...
return
}
这可能会有点重复。另一种方法是使用事务处理程序包装您的事务:
func Transact(db *sql.DB, txFunc func(*sql.Tx) error) (err error) {
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // re-throw panic after Rollback
} else if err != nil {
tx.Rollback() // err is non-nil; don't change it
} else {
err = tx.Commit() // err is nil; if Commit returns error update err
}
}()
err = txFunc(tx)
return err
}
使用上面的事务处理程序,我可以这样做:
func (s Service) DoSomething() error {
return Transact(s.db, func (tx *sql.Tx) error {
if _, err := tx.Exec(...); err != nil {
return err
}
if _, err := tx.Exec(...); err != nil {
return err
}
return nil
})
}
这使我的交易简洁明了,并确保交易得到妥善处理。
在我的事务处理程序中,我recover()用来捕获恐慌以确保立即发生回滚。如果预期会发生恐慌,我会重新抛出恐慌以允许我的代码捕获它。在正常情况下,不应发生恐慌。应该返回错误。
如果我们不处理恐慌,事务最终会回滚。当客户端断开连接或事务被垃圾收集时,数据库会回滚未提交的事务。但是,等待事务自行解决可能会导致其他(未定义的)问题。所以还是尽快解决比较好。
可能无法立即清楚的一件事是,defer如果返回变量被捕获,可以在闭包内更改返回值。在事务处理程序中,事务在err(返回值)为零时提交。对 的调用Commit也可以返回错误,因此我们将其返回值设置为 err err = tx.Commit()。我们不对Rollbackbecause erris non-nil 做同样的事情,我们不想覆盖现有的错误。
最终客户采用的解决方案也是stackoverflow.com高赞回答,如图所示:

客户解决bug后通过测试回复,如图所示

5、总结
其实问题的根源原因就是在线程中开启了事务,但是在线程出现panic的时候,未做事务回滚处理。由于gin框架会自动recover发生panic的线程,最终导致数据库线程池的泄露。