近期一同事负责的线上模块,总是时不时的返回一下 504,检查发现,这个服务的内存使用异常的大,pprof分析后,发现有上万个goroutine,排查分析之后,是没有规范使用gorm包导致的,那么具体是什么原因呢,会不会也像 《Go Http包解析:为什么需要response.Body.Close()》 文中一样,因为没有释放连接导致的呢?
问题现象
demo
首先我们先来看一个示例,然后,猜测一下打印的结果
1 | package main |
分析
我们先看一下上面的demo,貌似没有什么问题,我们就运行一段时间看看
有点尴尬,我就一简单的查询返回,怎么会有那么多goroutine?
继续看一下都是哪些函数产生了goroutine
startWatcher.func1
是个什么鬼
1 | func (mc *mysqlConn) startWatcher() { |
猜测验证
startWatcher
这个函数的调用者,只有 MySQLDriver.Open
会调用,也就是创建新的连接的时候,才会去创建一个监控者的goroutine
根据 《Go Http包解析:为什么需要response.Body.Close()》 中的分析结果,可以大胆猜测,有可能是mysql每次去查询的时候,获取一个连接,没有空闲的连接,则创建一个新的,查询完成后释放连接到连接池,以便下一个请求使用,而由于没有调用rows.Close(), 导致拿了连接之后,没有再放回连接池复用,导致每个请求过来都创建一个新的请求,从而导致产生了大量的goroutine去运行startWatcher.func1
监控新创建的连接 。所以我们类似于 response.Close 一样,进行一下 rows.Close() 是不是就ok了,接下来验证一下
对上面的测试代码增加一行rows.Close()
1 | defer rows.Close() |
继续观察goroutine的变化
goroutine 不再上升,貌似问题就解决了
疑问
- 我们一般写代码的时候,都不会调用
rows.Close()
的,很多情况下并没有出现goroutine的暴增,这是为什么
结构
照例,还是先把可能用到的结构体提前放出来,混个眼熟
rows
1 | // Rows is the result of a query. Its cursor starts before the first row |
查询
建立连接、scope结构体、Model、Where 方法的逻辑就不再赘述了,上一篇文章《GORM之ErrRecordNotFound采坑记录》已经粗略讲过了,直接进入Rows
函数的解析
Rows
1 | // Rows return `*sql.Rows` with given conditions |
感觉这里很快就进入了callback
的回调
根据上一篇文章的经验,rowQueries
所注册的回调函数,可以在 callback_row_query.go 中的 init() 函数中找到
1 | func init() { |
上面可以看到,rowQueryCallback
仅仅是组装了一下sql,然后又去调用go 提供的sql包,来进行查询
sql.Query
1 | // Query executes a query that returns rows, typically a SELECT. |
上面的逻辑理解不难,这里有两个变量,解释一下
cachedOrNewConn: connReuseStrategy 类型,本质是uint8类型,值是1,这个标志会传递给下面的db.conn
函数,告诉这个函数,返回连接的策略
1. 如果连接池中有空闲连接,返回一个空闲的
2. 如果连接池中没有空的连接,且没有超过最大创建的连接数,则创建一个新的返回
3. 如果连接池中没有空的连接,且超过最大创建的连接数,则等待连接释放后,返回这个空闲连接
alwaysNewConn:
- 每次都返回一个新的连接
获取连接
1 | // conn returns a newly-opened or cached *driverConn. |
在上面的逻辑中,可以看到,获取连接的策略跟我们上面解释 cachedOrNewConn 和 alwaysNewConn 时是一样的,但是,这里面有两个问题
- 创建的连接数量超过最大允许的连接数量,则等待一个空闲的连接,这时候为 db.connRequests 这个map新增加了一个key,这个key对应一个chan,然后直接等待这个 chan 吐出来连接,既然是等待释放空闲连接,那么这个chan 里面插入的 连接,应该是在freeconn 函数里面,freeconn的逻辑又是怎么样的呢
- 创建新连接失败后,会调用 db.maybeOpenNewConnections, 这个函数又不返回连接,那么它做了什么
释放连接
释放连接主要依靠 putconn
来完成的,在 conn
函数的下面代码中
1 | case ret, ok := <-req: |
也调用了,把获取到但不再需要的连接放回池子里,下面看一下释放连接的过程
putConn
1 | // putConn adds a connection to the db's free pool. |
putConnDBLocked
1 | func (db *DB) putConnDBLocked(dc *driverConn, err error) bool { |
梳理完释放连接的逻辑,我们可以看出连接复用的大致流程
- 一个新的请求过来,需要获取一个新的连接
- 首先判断是否有空闲连接,如果没有且没有超过允许创建的最大连接数,则创建一个
- 多个请求之后,连接数量已经超过了设定的最大连接数,则等待释放空闲连接
- 此时,第一个请求完成了,准备释放连接,去看一下有没有等待空闲连接的请求,如果有的话,则把这个连接通过chan直接传过去,否则,把这个连接放到空闲的连接池里面
- 此时,后面等待空闲连接的请求,拿到了第一个请求传递过来的连接,继续处理请求
- 以上,循环往复
###maybeOpenNewConnections
这个函数,在上面的分析中已经出现了两次了,先分析一下 这个函数到底做了什么
1 | func (db *DB) maybeOpenNewConnections() { |
在前面的分析中,如果在获取连接时,发现产生的连接数>= 最大允许的连接数,则在 db.connRequests 这个map中创建一个唯一的 key value,用于接收释放的空闲连接,但是如果在释放连接的过程中,发现这个连接失效了,这个连接就无法复用,这时候就会走到这个函数,尝试创建一个新的连接,给其他等待的请求使用
这里就会发现一个问题: 为什么 db.openerCh <- struct{}{}
这样一条简单的命令就能创建一个连接,接下来就需要分析 db.openerCh 的接收方了
###connectionOpener
这个函数在db结构体创建的时候,就会开始执行了,一个常驻的goroutine
1 | // Runs in a separate goroutine, opens new connections when requested. |
openNewConnection
1 | // Open one new connection |
Connect
这里是连接数据库的主要逻辑
1 | func (t dsnConnector) Connect(_ context.Context) (driver.Conn, error) { |
startWatcher
这个函数主要是对连接进行监控
1 | func (mc *mysqlConn) startWatcher() { |
创建连接的逻辑
- 首先尝试创建一个连接,如果失败,则再次调用maybeOpenNewConnections函数,再度尝试创建一个新的连接,直到创建成功或者没有请求方需要等待连接位置
- 新连接创建时,会调用startWatcher函数,一个常驻的goroutine,来对连接进行监控,及时的关闭
- 连接创建成功后,通过 putConnDBLocked,把连接交给等待连接的请求方或者放到连接池中
至此,基本上连接创建及复用的流程大概清晰了,至此,对于我们最开始遇到的问题也有了一个明确的解释:
- 调用 Rows() 函数进行查询的时候,需要获取一个连接
- 此时没有新的或空闲的连接,所以,需要创建一个新的连接
- 创建连接是,创建一个 startWatcher的goroutine来进行监控
- 由于 查询完成后,没有调用 rows.Close() 及时释放连接,导致此连接一直没有放回连接池或被复用,所以每次请求,都会创建一个新的连接
- 多次请求下来,就会创建很多的startWatcher的goroutine,最终产生了遇到的现象
Rows.Close
1 | func (rs *Rows) Close() error { |
rs.releaseConn 所对应的函数,可以在 queryDC 这个方法里面找到,这里就直接列出来了
可以看到,rows.Close() 最后就是通过 putConn
把当前的连接释放以便复用
Rows.Next
Next 为scan方法准备下一条记录,以便scan方法读取,如果没有下一行的话,或者准备下一条记录的时候出错了,就会返回false
1 | func (rs *Rows) Next() bool { |
Next() 的逻辑:
- 在调用Next() 的时候,准备下一条记录,以便scan读取
- 如果在准备数据的时候出错或者没有下一条记录的时候,返回false
- 如果Next() 在准备数据的时候,拿到了false,则调用 rows.Close() 把连接放回池子或者交给其他请求等待着,以便复用连接
所以,也就是为什么一下的demo并不会出现问题一样
1 | for rows.Next() { |
总结
走到这里,开头提出的问题应该已经有了明确的答案了: rows.Next() 在获取到最后一条记录之后,会调用 rows.Close() 将连接放回连接池或交给其他等待的请求方,所以不需要手动调用 rows.Close(),
而出问题的demo中,由于rows.Next() 没有执行到最后一条记录处,也没有调用 rows.Close(), 所以在获取到连接后一直没有被放回进行复用,导致了每来一个请求创建一个新的连接,产生一个新的监控者 startWatcher.func1
, 最终导致了内存爆炸💥