一行代码引发的协程泄露

前言

最终定位到的结果比较蠢,巨佬能够直接查看结果,本文只为记录寻找该bug时使用的方法而生。
本人平日维护一个公司运营平台的一个配置库,因为降级措施比较好,并且公司容器实例也比较多,因此在爆发错误的时候并未及时跟进。该问题应该从该服务出生开始就已经有了,一直没有爆发的缘由应该是由于之前迭代得比较多,一周也差很少会发布四到五次,再加上平台在去年使用人数仍是比较少的,后面逐渐成为公司一个中流砥柱的产品,被许多部门所使用,因此问题就爆发了。redis

第一次猜测

“这服务怎么协程忽然暴涨了”
第一次协程暴涨的时候,发现有一台实例忽然就跪了,协程数疯狂暴涨,内存也疯狂暴涨,第一反应是db出现了慢查询,可是实际上。而查看监控,并无特别多接口跪,如图。 缓存

接口报错

而实际上能很明显看到有几个实例的协程数直线上涨。 并发

其实由于服务设计缘由,没有那么多接口跪是符合预期的,由于其余接口都作了内存全表缓存,至于为何作内存全表缓存就后面再说吧。经过pprof定位,很容易就发现那几个跪了的接口都有一个共同点,协程泄露。
此时扫了一眼代码,那几个接口除了没有作缓存,其余实现都蛮合理的,由于怀疑是慢查询了,因此尝试去扫了一下表,大概最长耗时接近1s,该表数据也差很少到了3w多数据了,因此认为是由于扫这个表致使线程被占用,而当多台机子都并发读该接口时由于慢查询给堵住,没有超时设置因此获取协程的吞吐量不成比了。
因此就溜下去给这个接口作了内存缓存,暂时缓解了两三个星期,可是几周事后又出现了协程泄露,不得而解。。

第二次猜测

再次出现协程泄露时,没什么其余手段去排查了,只能干读代码,不是由于读库的缘由,那是由于什么呢?
产生了这个想法的我,看向了写操做的代码,有没有多是由于读写没分离,写超时了致使出现这个问题呢。
由于再次出现协程泄露的时间很奇特,在半夜四点,忽然给电话打醒。因此突发奇想,跟同事去查了一下当天凌晨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作缓存的,服务设计以下。

这样设计的优缺点是什么呢

优势:

  1. db压力小,数据量很少的状况下扫表问题不大。
  2. 当上游服务多时,实例充当了一个redis,db没有来自上游的请求压力。
  3. 没有序列化和反序列化的时间复杂度

缺点:

  1. 开发成本高,go没有泛型,为了减小序列化,代码写的比较暴力
  2. 更新效率慢,取决于数据轮训的时间,适用于不须要及时更新的数据

结语

这个服务是刚进公司的时候学到的,没想到会有隐藏问题,并且还藏了那么久。 服务设计是挺不错的,就是开发起来特别恶心。 仍是本身太菜了,多学点东西吧。

相关文章
相关标签/搜索