最终定位到的结果比较蠢,巨佬能够直接查看结果,本文只为记录寻找该bug时使用的方法而生。
本人平日维护一个公司运营平台的一个配置库,因为降级措施比较好,并且公司容器实例也比较多,因此在爆发错误的时候并未及时跟进。该问题应该从该服务出生开始就已经有了,一直没有爆发的缘由应该是由于之前迭代得比较多,一周也差很少会发布四到五次,再加上平台在去年使用人数仍是比较少的,后面逐渐成为公司一个中流砥柱的产品,被许多部门所使用,因此问题就爆发了。redis
“这服务怎么协程忽然暴涨了”
第一次协程暴涨的时候,发现有一台实例忽然就跪了,协程数疯狂暴涨,内存也疯狂暴涨,第一反应是db出现了慢查询,可是实际上。而查看监控,并无特别多接口跪,如图。 缓存
而实际上能很明显看到有几个实例的协程数直线上涨。 并发
再次出现协程泄露时,没什么其余手段去排查了,只能干读代码,不是由于读库的缘由,那是由于什么呢?
产生了这个想法的我,看向了写操做的代码,有没有多是由于读写没分离,写超时了致使出现这个问题呢。
由于再次出现协程泄露的时间很奇特,在半夜四点,忽然给电话打醒。因此突发奇想,跟同事去查了一下当天凌晨4点平台的操做,发现确实,有一我的在晚上4点的时候申请了一个东西,写了库,时间刚好吻合。
因此确实是由于写数据的问题了,因而我又去扫了一眼代码,发现整个库表都是读主库的,从库历来不用,emmmmmm。因而就给服务作了一次读写分离,作起来也不复杂。
不出所望过了两周,问题又爆发了。。框架
首先,我在质疑代码里的timeout真的是奏效的吗,为何明明设置了超时时间还会超时,并且不单只只有代码经过框架设置了超时,db平台也设置了超时,按道理来讲应该会kill掉的。不会是某个时刻操做比较多库比较渣因此就跪了,可是实际上操做的qps并不高,秉着质疑,我发现,线程池里只设置了10个线程数,对于这个服务来讲10个线程彻底不够啊,因此我就跑去扩线程了。
不出所望依然是过了一两周,问题又来了。。ui
此次是大半夜忽然连续出了两三次,并且出的频率还特别高,迁移了实例后过一两个钟又会有实例协程泄露。因此刚好发现,全部会形成忽然协程泄露的操做,都是调用了一个写数据的接口致使的。把接口来回看了好几回,硬是没看出问题。可是我意识到一件事,这个接口调用了三次db,而我设置db查询/写的超时时间是3s,而上游却过了10s才熔断,那么按道理来讲应该是9s后会超时,感受到了事情有一点不对。
因此再一次协程泄露时,我暂时没有迁移实例,而是爬上了实例查看实例日志,发现某一行debug日志并未执行。看来问题已经缩小到某几行代码了。
没错,我在开始怀疑gorm框架有问题了,看起了源码。发现咱们实现查找最大值的代码很神奇,居然是拿了rows
。代码以下。spa
func GetMax(ctx context.Context) (int64, error) {
var max int64 = 0
db, err := GetConnection(ctx, DatabaseName)
if err != nil {
return max, err
}
rows, err := db.Table(TableName).Select("max(xxx) as max").Rows()
if err != nil {
return max, err
}
if rows.Next() {
err := rows.Scan(&max)
if err != nil {
return max, err
}
}
return max, nil
}
复制代码
乍一眼看,貌似没啥问题,看一下next
的源码,在没有下一行数据时,不会将rows close
掉,代码以下,代码来自go 1.13.4源码,只有中文注释是本身加上的:线程
func (rs *Rows) Next() bool {
var doClose, ok bool
withLock(rs.closemu.RLocker(), func() {
doClose, ok = rs.nextLocked()
})
if doClose { // 这里当须要close的时候会将线程释放掉
rs.Close()
}
return ok // 而返回的true/false是决定能不能拿到数据的
}
func (rs *Rows) nextLocked() (doClose, ok bool) {
if rs.closed {
return false, false
}
// Lock the driver connection before calling the driver interface
// rowsi to prevent a Tx from rolling back the connection at the same time.
rs.dc.Lock()
defer rs.dc.Unlock()
if rs.lastcols == nil {
rs.lastcols = make([]driver.Value, len(rs.rowsi.Columns()))
}
rs.lasterr = rs.rowsi.Next(rs.lastcols)
if rs.lasterr != nil {
// Close the connection if there is a driver error.
if rs.lasterr != io.EOF {
return true, false
}
nextResultSet, ok := rs.rowsi.(driver.RowsNextResultSet)
if !ok {
return true, false
}
// The driver is at the end of the current result set.
// Test to see if there is another result set after the current one.
// Only close Rows if there is no further result sets to read.
if !nextResultSet.HasNextResultSet() { // 当没有下一行数据时,next是false,close是true
doClose = true
}
return doClose, false
}
return false, true
}
复制代码
咦这个不close
真的不会有问题吗? 咱们再看看这个rows
是从哪里拿出来的,没错线程池,那么不close掉是否是会致使这个线程不会放回去线程池里呢? 咱们看看close的代码,代码以下:debug
func (rs *Rows) close(err error) error {
rs.closemu.Lock()
defer rs.closemu.Unlock()
if rs.closed {
return nil
}
rs.closed = true
if rs.lasterr == nil {
rs.lasterr = err
}
withLock(rs.dc, func() {
err = rs.rowsi.Close()
})
if fn := rowsCloseHook(); fn != nil {
fn(rs, &err)
}
if rs.cancel != nil {
rs.cancel()
}
if rs.closeStmt != nil {
rs.closeStmt.Close()
}
rs.releaseConn(err) // 这里会释放链接
return err
}
复制代码
事实上,应该是的。因此事情到这里应该是已经解决了。 解决完的代码以下:设计
func GetMax(ctx context.Context) (int64, error) {
var max int64 = 0
db, err := GetConnection(ctx, DatabaseName)
if err != nil {
return max, err
}
rows, err := db.Table(TableName).Select("max(xxx) as max").Rows()
if err != nil {
return max, err
}
defer rows.Close() // 只由于少了这一行
if rows.Next() {
err := rows.Scan(&max)
if err != nil {
return max, err
}
}
return max, nil
}
复制代码
可是实际上取max值只会有一个值,为什么会使用rows而不使用row呢?不得而知。由于根据go源码来看若是这么写的话是不须要本身去close掉线程的。日志
for rows.Next() { // 这里改为循环一直走下一个
err := rows.Scan(&max)
if err != nil {
return max, err
}
}
复制代码
还有另外一个问题是,为何会两周一次呢,算一算,实例差很少有15台,而进行这个查max的操做只有在申请某个配置的时候才会触发,而线程池里有10个线程,因此在假设请求是均衡的状况下,要申请100+次才会开始命中这个问题。并且也只有db线程数不够的机器才会出现这种问题,再加上这个服务已经相对比较稳定了,好久没有怎么加过需求了,因此容器不会重启,内存不会重置。至于GC到底能不能把解决这个问题呢,应该是不行的,由于解决了只会让你的线程数减小。
哎,真的是菜,定位问题都那么难。主要仍是没啥经验吧,全部接口都报错了,一开始无从下手,直到某天凌晨给报警电话打醒,才突发奇想去定位这个问题。
为何要设计用内存呢? 首先,表很少,其次,数据也不太多平均每张表也就5k,并且,因为并不但愿上游每次拿数据都须要请求这个服务,因此须要扫表。基于以上缘由,该服务是没有用redis作缓存的,服务设计以下。
优势:
缺点:
这个服务是刚进公司的时候学到的,没想到会有隐藏问题,并且还藏了那么久。 服务设计是挺不错的,就是开发起来特别恶心。 仍是本身太菜了,多学点东西吧。