Golang中SQL连接池的使用方法总结

发表时间: 2024-05-08 10:05

此篇文章主要分析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

1.为什么需要连接池

连接池的最基本作用是复用已建立的连接,当建立连接的开销比较大时,复用连接的优势非常明显。

相比于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()`,有兴趣的同学可以自行测试。

2.连接的状态管理

复用连接的优势是非常明显,但写过PHP的都知道,这些`持久连接`的管理要很小心,所以以前我们的PHP守护进程会定时重启,为的就是让其重新创建mysql连接。

连接池的出现就解决了连接的管理问题,尤其是生命周期的管理。

在sql.go为连接定义了若干个状态,简单来说,主要的状态包括`inUse`、`idle`和`closed`,状态机表述如下:


idle->inUse

从`idle`到`inUse`转换比较好理解,当要需要执行sql时,得先要获取一个连接,这时候将面临3种选择,参考代码:

1). 是否有`idle`状态的连接,有的话直接拿来用即可,此连接状态转为`inUse`;
2). 若没有`idle`状态的连接,根据配置判断否是能创建新连接(是否超过maxOpen),如果不能,则等待`idle`状态的连接,此操作是阻塞的;
3). 如果能创建创建连接,则创建新连接

idle→closed

什么时候关闭连接?有4种情况:

1). 执行sql,返回ErrBadConn错误,那就直接关闭,并创建一个新连接。所以不用担心这些坏连接一直保留,参考代码;
2). 连接超过了最大生命时间(maxLifetime)或者最大空闲时间(maxIdleTime),为了及时能把这些连接关闭,连接池起了一个异步协程定时处理,参考代码 ;
3). 超过了空闲连接最大值(maxIdleCount),参考代码;
需要注意的是,能否创建连接由maxOpen控制,而创建后是否能放回连接池,则由maxIdleCount控制。

4). 连接池关闭参考代码 ;

3.连接池状态与可视化

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限制而关闭的连接数


gorm_dbstats_max_idleTime_closed(
暂不可用,已提pr

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()`配置过小,导致大量已生成的连接没法复用。

4.小结

Golang sql连接池的实现其实并不算复杂,源码就一个文件sql.go,是学习源码的较好案例。理解sql连接池,其他类型的连接池应该也是类似的。

在实际编码中,切记需要根据监控指标及时调整连接池参数。


留一个思考问题:如果项目请求量低,但`SetMaxIdleConns()`和`SetMaxOpenConns()`设得很大,是否会浪费连接?导致连接一直放在池子里没有用?



作者:万梓荣

来源-微信公众号:三七互娱技术团队

出处
:https://mp.weixin.qq.com/s/CyIxyjnq6VWGSzGnYGZBpA