此篇文章主要分析Golang数据库连接池的原理与使用
Golang版本: 1.16.7
sql连接池源码:
https://github.com/golang/go/blob/go1.16.7/src/database/sql/sql.go
common-library版本: v0.5.5
连接池的最基本作用是复用已建立的连接,当建立连接的开销比较大时,复用连接的优势非常明显。
相比于PHP PDO或MySQLi只提供`Persistent Connections`,Golang提供了连接池,具备了管理连接的能力。
下面通过一段代码展示Golang sql连接池的优势,代码很简单,就是执行两次insert,如下:
func main() { //firstly insert m := model.EnterGameLog{} t1 := time.Now() if err := dao.EnterGameLog(context.Background()).Create(&m); err != nil { log.Fatalln(err) } log.Println("first cost", time.Since(t1)) //secondly insert t2 := time.Now() if err := dao.EnterGameLog(context.Background()).Create(&m); err != nil { log.Fatalln(err) } log.Println("second cost", time.Since(t2)) log.Println("done")}
我本地网络不太好,连接远程DB延迟60ms左右,如下:
两次insert的耗时如下:
2022/11/26 20:59:37 <nil>2022/11/26 20:59:38 first cost 493.397869ms2022/11/26 20:59:38 second cost 62.261088ms2022/11/26 20:59:38 done
两次insert相差400多ms,那么时间到底花在哪里?我们可以用trace分析,调整代码如下:
import "runtime/trace"func main() { f1, _ := os.Create("trace1.out") f2, _ := os.Create("trace2.out") defer func() { f1.Close() f2.Close() }() //firstly insert trace.Start(f1) t1 := time.Now() if err := dao.EnterGameLog(context.Background()).Create(&m); err != nil { log.Fatalln(err) } log.Println("first cost", time.Since(t1)) trace.Stop() //secondly insert trace.Start(f2) t2 := time.Now() if err := dao.EnterGameLog(context.Background()).Create(&m); err != nil { log.Fatalln(err) } log.Println("second cost", time.Since(t2)) trace.Stop() log.Println("done")}
编译后执行就会生成两个跟踪文件trace1.out和trace2.out,用go tool分析:
go tool trace trace1.outgo tool trace trace2.out
我们重点分析网络耗时,点击"Network blocking profile"即可:
看到的profile图如下:
trace1.svg
trace2.svg
在`Dao`中,`dao.EnterGameLog(context.Background()).Create(&m)`真正调用的是`enterGameLogDao.DB().Create(entity)`,因此总耗时=`DB()`+`Create()`。
`DB()`和`Create()`可以继续细分,见下表:
DB() | Create() | |||
sql.(*DB).conn : 创建连接 | (*DB).pingDC() : 执行ping | stmtcache.(*LRU).Get: 请求生成预处理的statment并缓存 | pgx.(*Conn).execPrepared: 执行insert | |
First | 297.51ms | 52.28ms | 53.71ms | 69.97ms |
Second | - | - | - | 61.50ms |
第二次请求之所以耗时短,是因为把创建连接、ping和创建statment的时间都省了,这就是复用连接的优势。
多说一句,第一次请求在`DB()`为什么会执行`Ping()`? 其实是由`gorm`的一个参数控制,如下:
if err == nil && !config.DisableAutomaticPing { if pinger, ok := db.ConnPool.(interface{ Ping() error }); ok { err = pinger.Ping() }}
如果不启用ping,那么`DB()`只是初始化连接池,真正的创建连接就会留到`Create()`,有兴趣的同学可以自行测试。
复用连接的优势是非常明显,但写过PHP的都知道,这些`持久连接`的管理要很小心,所以以前我们的PHP守护进程会定时重启,为的就是让其重新创建mysql连接。
连接池的出现就解决了连接的管理问题,尤其是生命周期的管理。
在sql.go为连接定义了若干个状态,简单来说,主要的状态包括`inUse`、`idle`和`closed`,状态机表述如下:
从`idle`到`inUse`转换比较好理解,当要需要执行sql时,得先要获取一个连接,这时候将面临3种选择,参考代码:
1). 是否有`idle`状态的连接,有的话直接拿来用即可,此连接状态转为`inUse`;
2). 若没有`idle`状态的连接,根据配置判断否是能创建新连接(是否超过maxOpen),如果不能,则等待`idle`状态的连接,此操作是阻塞的;
3). 如果能创建创建连接,则创建新连接;
什么时候关闭连接?有4种情况:
1). 执行sql,返回ErrBadConn错误,那就直接关闭,并创建一个新连接。所以不用担心这些坏连接一直保留,参考代码;
2). 连接超过了最大生命时间(maxLifetime)或者最大空闲时间(maxIdleTime),为了及时能把这些连接关闭,连接池起了一个异步协程定时处理,参考代码 ;
3). 超过了空闲连接最大值(maxIdleCount),参考代码;
需要注意的是,能否创建连接由maxOpen控制,而创建后是否能放回连接池,则由maxIdleCount控制。
4). 连接池关闭,参考代码 ;
sql连接池配置里,最重要的是以下4个选项:
db.SetMaxIdleConns()db.SetMaxOpenConns()db.SetConnMaxLifetime()db.SetConnMaxIdleTime()
怎么配置才是最优?当然会有一些一般性的原则,例如有人提出这样的公式,最大连接数= ((核心数* 2) + 有效磁盘数。
但除了一般性原则,更重要的是要根据连接池状态进行调整。假如创建连接的代价非常大,就像我在本地测试的,创建一个连接要300ms,这时候CPU调度的影响可能变得不那么紧要了,就可以提高最大连接数,不必拘泥于2倍或者3倍CPU核心数。
那么怎样才知道连接池的状态?源码中是有记录状态值的,参考代码如下:
type DBStats struct { MaxOpenConnections int // Maximum number of open connections to the database. // Pool Status OpenConnections int // The number of established connections both in use and idle. InUse int // The number of connections currently in use. Idle int // The number of idle connections. // Counters WaitCount int64 // The total number of connections waited for. WaitDuration time.Duration // The total time blocked waiting for a new connection. MaxIdleClosed int64 // The total number of connections closed due to SetMaxIdleConns. MaxIdleTimeClosed int64 // The total number of connections closed due to SetConnMaxIdleTime. MaxLifetimeClosed int64 // The total number of connections closed due to SetConnMaxLifetime.}
配合gorm的prometheus plugin,可以方便地把这些状态值读出来,转换为指标,如下:
指标 | 类型 | 含义 |
gorm_dbstats_max_open_connections | Gauge | 配置的maxOpen值 |
gorm_dbstats_open_connections | Gauge | 当前已打开的连接数,值为idle+inUse |
gorm_dbstats_in_use | Gauge | 当前inUse的连接数 |
gorm_dbstats_idle | Gauge | 当前idle的连接数 |
gorm_dbstats_wait_count | Count | 当前累计,等待连接次数 |
gorm_dbstats_wait_duration | Count | 当前累计,等待连接时间 |
gorm_dbstats_max_idle_closed | Count | 当前累计,因最maxIdleConns限制而关闭的连接数 |
gorm_dbstats_max_lifetime_closed | Count | 当前累计,因最maxLifetime限制而关闭的连接数 |
| Count | 当前累计,因最maxIdleTime限制而关闭的连接数 |
有时候我们发现连接数据库或者执行sql很慢,那么就需要关注以下连接池指标:
1). gorm_dbstats_wait_count
如果这个指标值的增速不为0,那么就说明有不少等待连接,即`SetMaxOpenConns()`不够大,或者sql执行过慢导致连接释放不及时,这时候可以适当调高`SetMaxOpenConns()`。
2). gorm_dbstats_max_idle_closed
如果这个指标值的增速比较快,说明创建的连接很多都被close,无法放回池子被复用,这时候就要适当调高`SetMaxIdleConns()`。
这个case用一段代码解释:
//连接池配置://maxOpenConns = 40//maxIdleConns = 2func main() { curr := 40 //持续以并发40执行SQL for { var wg sync.WaitGroup for i := 0; i < curr; i++ { wg.Add(1) go func() { defer wg.Done() if err := dao.EnterGameLog(ctx).Create(&model.EnterGameLog{}); err != nil { log.Fatalln(err) } }() } wg.Wait() time.Sleep(100 * time.Millisecond) }}
这时候就会出现如下的监控图:
上周消费进程写入Hologres慢的问题,就是因为`SetMaxIdleConns()`配置过小,导致大量已生成的连接没法复用。
Golang sql连接池的实现其实并不算复杂,源码就一个文件sql.go,是学习源码的较好案例。理解sql连接池,其他类型的连接池应该也是类似的。
在实际编码中,切记需要根据监控指标及时调整连接池参数。
留一个思考问题:如果项目请求量低,但`SetMaxIdleConns()`和`SetMaxOpenConns()`设得很大,是否会浪费连接?导致连接一直放在池子里没有用?
作者:万梓荣
来源-微信公众号:三七互娱技术团队
出处
:https://mp.weixin.qq.com/s/CyIxyjnq6VWGSzGnYGZBpA