Golang 侧数据库连接池原理和参数调优

Golang 侧数据库连接池原理和参数调优


池化技术 (Pool) 是一种很常见的编程技巧,在请求量大时能明显优化应用性能,降低系统频繁建连的资源开销。我们日常工作中常见的有数据库连接池、线程池、对象池等,它们的特点都是将 “昂贵的”、“费时的” 的资源维护在一个特定的 “池子” 中,规定其最小连接数、最大连接数、阻塞队列等配置,方便进行统一管理和复用,通常还会附带一些探活机制、强制回收、监控一类的配套功能。下文主要介绍了数据库连接池的一些基本概念,以及 Go 语言中数据库连接池的设计和实现。


数据库连接池

数据库连接是一种关键的、有限的、昂贵的资源。对数据库连接的管理能显著影响到整个应用程序的伸缩性和健壮性,影响到程序的性能指标。数据库连接池正是针对这个问题提出来的。

数据库连接池负责分配、管理和释放数据库连接,它允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个;并且释放空闲时间超过最大空闲时间的数据库连接来避免因为没有释放数据库连接而引起的数据库连接遗漏。这项技术能明显提高数据库操作的性能。

数据库连接池技术带来的优势:

  1. 资源重用
    • 由于数据库连接得到重用,避免了频繁创建、释放连接引起的大量性能开销。在减少系统消耗的基础上,另一方面也增进了系统运行环境的平稳性(减少内存碎片以及数据库临时进程/线程的数量)。
  2. 更快的系统响应速度
    • 数据库连接池在初始化过程中,往往已经创建了若干数据库连接置于池中备用。此时连接的初始化工作均已完成。对于业务请求处理而言,直接利用现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。
  3. 新的资源分配手段
    • 对于多应用共享同一数据库的系统而言,可在应用层通过数据库连接的配置,实现数据库连接池技术。通过对应用最大可用数据库连接数的限制,避免某一应用独占所有数据库资源。
  4. 统一的连接管理,避免数据库连接泄漏
    • 在较为完备的数据库连接池实现中,可根据预先的连接占用超时设定,强制收回被占用连接,从而避免了常规数据库连接操作中可能出现的资源泄漏。

下面简要介绍一下数据库连接池的设计。


数据库连接池的设计

数据库连接池 = 空闲连接池 + 工作连接池

数据库连接池有三个重要的参数:

  1. 最大连接数
    • 最大连接数是对 Connection 总数的限制,一般是((核心数 * 2) + 有效磁盘数),在附录中有详细说明
  2. 空闲连接数
    • 空闲连接数表示当前的空闲连接池中的 Connection 的数量,规定最大值和最小值
    • 当前连接数 < 最大连接数:表示用户有机会获取连接
      • 如果空闲连接池的 size>0,直接获取连接
      • 否则,创建一个新的 Connection 给用户
    • 当前连接数 >= 最大连接数:Connection 的创建达到了上限,用户只能等待重试
  3. 工作连接数
    • 工作连接数表示当前工作连接池中的 Connection 的数量

其中最重要的两个步骤,就是获取连接和释放连接:

  • 获取连接:空闲连接池中的弹出一个 Connection,把当前的 Connection 加入到工作连接池。
  • 释放连接:如果空闲连接池未满,直接添加进去,并把工作连接池中相应的连接移除;如果空闲连接池满了,直接close()掉,并把工作连接池中相应的连接移除。

下面主要介绍一下 Go 的数据库连接池。


Go 的数据库连接池

在 Go 语言中对数据库进行操作需要借助标准库下的 database/sql 包进行,它对上层应用提供了标准的 API 操作接口,对下层驱动暴露了简单的驱动接口,并在内部实现了连接池管理。这意味着不同数据库的驱动可以很方便地实现这些驱动接口,但不再需要关心连接池的细节,只需要基于单个连接。

在这里插入图片描述

下面通过源码来看一下 database/sql 中的数据库连接池。实际上这并不代表一个连接池,也不代表一个数据库连接,而是 sql 包为了实现并发访问安全控制,连接池等诸多功能而设计的一个综合抽象数据结构。我们暂且称为 sql.DB 数据库连接池。

type DB struct {
    // ... 省略

	freeConn     []*driverConn                  // 空闲连接数组
	connRequests map[uint64]chan connRequest    // 阻塞的请求队列,连接数达到最大限制后,后续请求将插入此队列中等待可用连接
	numOpen      int                            // 已建立和等待被建立的连接数目
	
	openerCh          chan struct{}             // 消息队列,生产者(maybeOpenNewConnections)发送建立连接请求,消费者(connectionOpener)取出请求并且异步建立连接
	maxIdle           int                       // 最大的空闲连接数
	maxOpen           int                       // 最大连接数,0表示不受限制
	maxLifetime       time.Duration             // 连接复用的最大生命周期
	cleanerCh         chan struct{}             // 清理连接的 channel
}

基于 database/sql 实现的 gorm 框架也是应用了 sql.DB 数据库连接池,所以其实现逻辑和 sql.DB 是完全相同的。sql.DB 数据库连接池的实现逻辑为:

在这里插入图片描述

下面详细介绍一下 Go 数据库连接池的设计。


Go 数据库连接池的设计

上文提到数据库连接池中最重要的两个步骤,就是获取连接和释放连接,还有一个清理连接。下面从这三个方面深入讲解一下 Go 数据库连接池的设计。

建立连接

sql.DB 中的数据库连接并不是在 sql.Open 返回 db 对象时就建立的,这一步仅仅开了个接收建连请求的 channel,实际建连步骤要等到执行具体 SQL 语句时才会进行。

在 database/sql 对上层应用暴露的操作接口中,比较常用的是 Exec 和 Query,前者常用于执行写 SQL,后者可以用于读 SQL。但是不论走哪个方法,都会调用到建连逻辑 db.conn 方法,附带建连上下文和建连策略两个参数。

在这里插入图片描述

其中建连策略分为 cachedOrNewConn 和 alwaysNewConn。前者优先从 freeConn 空闲连接中取出连接,否则就新建一个;后者则永远走新建连接的逻辑。

使用 cachedOrNewConn 策略的建连逻辑中,会先判断是否有空闲连接,如果有取出首个空闲连接,紧接着判断该连接是否过期需要被回收,如果没有过期则可以正常使用进入后续逻辑。如果没有空闲连接则判断连接数是不是已经达到最大,若没有可以新建连接,反之就得阻塞这个请求让它等待可用连接。

如果需要新建连接,则调用底层 Driver 实现的连接器的 Connect 接口,这部分需要由各个数据库 Driver 来实现。

sql.DB 会优先尝试 cachedOrNewConn 建连策略,只有在失败了一定次数之后,才会尝试 alwaysNewConn 建连策略。

在这里插入图片描述

释放连接

某个连接使用完毕之后需要归还给连接池,这也是数据库连接池实现中比较重要的逻辑,通常还伴随着对连接的可靠性检测,如果连接异常关闭,那么不应该继续还给连接池,而是应该新建一个连接进行替换。

在这里插入图片描述

清理连接

database/sql 包下提供了与连接池相关的三个关键参数设置,分别是 maxIdle、maxOpen 和 maxLifeTime。

一个数据库连接无法保证长期有效,例如,MySQL 侧会强制 kill 掉长时间空闲的连接(8h)。在 sql.DB 中提供了 maxLifeTime 选项设置连接被复用的最大时间,注意并不是连接空闲时间,而是从连接建立到这个时间点就会被回收,从而保证连接活性。

sql.DB 的清理机制是通过一个异步任务来做的,关键是逻辑是每个一秒遍历检查 freeConn 中的空闲连接,判断是否超出最大复用期限,超出的连接加入 Closing 数组,后续被 Close。

下面详细介绍一下 Go 数据库连接池的三个关键参数应该如何配置以获得更好的性能。


配置 sql.DB 以获得更好的性能

sql.DB 通过 SetMaxOpenConns(),SetMaxIdleConns() 和 SetConnMaxLifetime() 方法来配置maxIdle、maxOpen 和 maxLifeTime。

下面分别解释这些设置的作用,并说明它们可能产生的(正面和负面)影响。

maxOpen

默认情况下,可以同时打开的连接数没有限制。可以通过如下SetMaxOpenConns()方法实现自己的限制。为了说明更改的影响,对 MaxOpenConns 进行了基准测试,将最大打开连接数设置为1、2、5、10和无限制。基准测试 INSERT 在 PostgreSQL 数据库上执行并行语句。结果如下:

BenchmarkMaxOpenConns1-8                 500       3129633 ns/op         478 B/op         10 allocs/op
BenchmarkMaxOpenConns2-8                1000       2181641 ns/op         470 B/op         10 allocs/op
BenchmarkMaxOpenConns5-8                2000        859654 ns/op         493 B/op         10 allocs/op
BenchmarkMaxOpenConns10-8               2000        545394 ns/op         510 B/op         10 allocs/op
BenchmarkMaxOpenConnsUnlimited-8        2000        531030 ns/op         479 B/op          9 allocs/op
PASS

通过 -benchmem 运行基准测试可以查看内存分配,其中 B/op 表示每次执行会分配多少内存,allocs/op 表示每次执行会发生多少次内存分配。

对于此基准,我们可以看到允许的开放连接越多,在数据库上执行连接所需的时间就越少(具有1个开放连接的 3129633 ns/op 与无限制连接的 531030 ns/op相比——快6倍)。这是因为存在的打开连接越多,基准代码等待释放打开的连接并再次使其空闲(准备使用)所需的时间(平均时间)就越短。

但是数据库连接池中的开放连接数并不是越多越好,关于这一点在附录中给出了详细的说明和解释。

maxIdle

默认情况下sql.DB,最多允许2个空闲连接保留在连接池中。可以通过如下SetMaxIdleConns()方法更改此设置。

从理论上讲,在池中允许更多数量的空闲连接将提高性能,因为这使得从头开始建立新连接的可能性降低了,因此有助于节省资源。下面将最大空闲连接数设置为none,1、2、5和10(并且打开的连接数不受限制)运行基准测试。结果如下:

BenchmarkMaxIdleConnsNone-8          300       4567245 ns/op       58174 B/op        625 allocs/op
BenchmarkMaxIdleConns1-8            2000        568765 ns/op        2596 B/op         32 allocs/op
BenchmarkMaxIdleConns2-8            2000        529359 ns/op         596 B/op         11 allocs/op
BenchmarkMaxIdleConns5-8            2000        506207 ns/op         451 B/op          9 allocs/op
BenchmarkMaxIdleConns10-8           2000        501639 ns/op         450 B/op          9 allocs/op
PASS

当 MaxIdleConns 设置为 none 时,必须为每个连接从头开始创建一个新连接,从基准测试中我们可以看到平均运行时间和内存使用率相对较高。

仅保留1个空闲连接并重用它就对这个特定的基准产生了巨大的影响——它使平均运行时间减少了大约8倍,并将内存使用量减少了大约20倍。继续增加空闲连接池的大小可以使性能更好,尽管改进的效果并不明显。

但是空闲连接池也不是越大越好。首先,保持空闲连接的存在是有代价的,它占用了内存;其次如果连接闲置时间过长,也有可能变得无法使用。例如,MySQL 的 wait_timeout 设置将自动关闭8个小时未使用的任何连接(默认情况下)。

当发生这种空闲连接不可用的情况时,sql.DB 会默认重试两次,若都失败则从池中删除该连接并创建一个新连接。所以过高的 MaxIdleConns 实际上会导致连接变得不可用,并占用更多的资源。最佳的情况是空闲连接池中连接较少并且被频繁的使用,所以应当只在需要很快再次使用此连接的时候,将它保持空闲。

最后要指出的是,MaxIdleConns 应始终小于或等于 MaxOpenConns。Go 会强制执行此操作,使 MaxIdleConns 在必要时自动减少。

maxLifeTime

SetConnMaxLifetime() 设置了连接可重用的最大时间长度。

db.SetConnMaxLifetime(time.Hour)

在如上示例中,设置了连接可重用的最大时间长度为一小时,则所有的连接将在首次创建后1小时“过期”,并且在它们过期后将无法重用。但需要注意的是:

  1. 这不能保证连接将在池中存在一个小时。很可能由于某种原因该连接将变得无法使用,并在此之前自动关闭。
  2. 建立连接后,仍然可以使用超过一小时,但在那之后,它就无法开始重用。
  3. 这不是空闲超时。连接将在第一次创建后1个小时到期,而不是在上一次空闲后1个小时到期。
  4. 每秒执行一次清理操作,以自动删除池中的“过期”连接。

从理论上讲,ConnMaxLifetime 连接越短,连接终止的频率就越高,因此,需要从头开始创建连接的频率就越高。下面将 ConnMaxLifetime 设置为100ms,200ms,500ms,1000ms和无限制(永远重复使用)进行基准测试,并使用无限制的开放连接数目和2个空闲连接的默认设置。结果如下:

BenchmarkConnMaxLifetime100-8               2000        637902 ns/op        2770 B/op         34 allocs/op
BenchmarkConnMaxLifetime200-8               2000        576053 ns/op        1612 B/op         21 allocs/op
BenchmarkConnMaxLifetime500-8               2000        558297 ns/op         913 B/op         14 allocs/op
BenchmarkConnMaxLifetime1000-8              2000        543601 ns/op         740 B/op         12 allocs/op
BenchmarkConnMaxLifetimeUnlimited-8         3000        532789 ns/op         412 B/op          9 allocs/op
PASS

它们的平均运行时间都差不多,但是可重用时间越短,内存的使用率就越高,内存的重利用率也越低。


在实践和压测中配置 Go 数据库连接池

在会员系统的一次压测中,压测效果不佳。但是仅仅通过改变了 Golang 数据库连接池中最大连接数目,便获得了很好的压测效果。

下面我实际设计了一个小项目,以测试 Go 数据库连接池的配置对压测效果的影响。

在4核8GB的开发机上部署了一个 Gin 项目和一个 MySQL 数据库。其中 Gin 对外提供的接口为:

engine.GET("/set", SetDBPool)
engine.GET("/insert", Insert)

分别为配置数据库连接池的接口和获取数据库连接插入数据的接口。

因为 MySQL 数据库的最大连接数为100,所以压测测试中使用的 Go 数据库连接池的最大连接数和最大空闲连接数的配置分别为1到100。压测的并发流量为1000,测试的指标为99耗时,对测试结果绘制曲面图如下:

  • 曲面图

在这里插入图片描述

其中X轴和Y轴分别是最大连接数和最大空闲连接数,Z轴为请求的99耗时。

可以看出数据库连接池的引入对压测效果的提示有很大的影响,仅仅是少量几个最大连接数,99耗时就下降了不少,但是随着最大连接数的增加,压测效果的提升幅度也在逐渐减小,因为压测流量的原因(1000并发),在大约50个最大连接数的时候压测效果已达到最优。而最大空闲连接数的增加对压测效果的提升并不明显,但是也不能太小,一般大于最大连接数的一半,即可达到最优的性能。


附录一:超出连接设置

这里有一个特殊情况,那就是数据库连接池的连接数超过数据库的硬性限制时会发生什么。通过更改配置文件,设置为仅允许5个连接。

max_connections = 5

然后设置数据集连接池的最大连接池为无限制,进行基准测试。结果如下:

BenchmarkMaxOpenConnsUnlimited-8    --- FAIL: BenchmarkMaxOpenConnsUnlimited-8
    main_test.go:14: pq: sorry, too many clients already
    main_test.go:14: pq: sorry, too many clients already
    main_test.go:14: pq: sorry, too many clients already
FAIL

一旦达到5个连接的硬限制,数据库驱动程序立即返回 sorry, too many clients already 的错误消息。为了防止出现此错误,我们需要将打开和空闲连接的总和设置为数据库允许的连接数以下。


附录二:如何配置一个高性能的数据库连接池

数据库连接池应该设置多大?这里是经常踩坑的地方,下文的目的是明确一个与直觉背道而驰的原则。

首先考虑有这样一个大型网站,并发的访问量大概是1万左右,那么TPS大概是2万,这时数据库连接池应该设置为多大呢?

下面以Oracle Real World Performance Group发布的视频为例,简要说明一下数据库连接池的大小应如何选择。

视频中对Oracle数据库进行压力测试,9600并发线程进行数据库操作,每两次访问数据库的操作之间sleep 550ms,一开始设置的数据库线程池大小为2048。此时每个请求要在连接池队列里等待33ms,获得连接后执行SQL需要77ms。

接下来,把数据库连接池的数量减到1024,其他条件不变。那么以你的直觉来看,执行耗时是会增加还是减少呢?事实的情况是,现在每个请求的等待时间基本没有变化,但是SQL的执行耗时减少到了30ms。

下面把数据库连接池减到96,还是其他条件不变,结果是:队列平均等待1ms,执行SQL平均耗时2ms。

没有调整任何其他东西,仅仅只是缩小了数据库连接池,就把请求响应时间从100ms左右缩短到了3ms

这是什么原因呢?

其实看起来违背常理,但是只要细心考虑,相信可以发现其中的玄机。这其实和nginx只用4个线程发挥出的性能就大大超越了100个进程的Apache HTTPD是一个道理。

回想一下计算机科学的基础知识。即使是单核CPU的计算机也能“同时”运行数百个线程,但这只不过是操作系统用时间分片玩的一个小把戏。一颗CPU核心同一时刻只能执行一个线程,然后操作系统切换上下文,核心开始执行另一个线程的代码,以此类推。给定一颗CPU核心,其顺序执行A和B永远比通过时间分片“同时”执行A和B要快,这是一条计算机科学的基本法则。一旦线程的数量超过了CPU核心的数量,再增加线程数系统就只会更慢,而不是更快。

所以仅从CPU的角度来考虑,设置线程数等于CPU核心数,能提供最优的性能

但是还要考虑磁盘,网络等因素。之所以产生线程的切换,就是因为在I/O等待时间内,线程是在“阻塞”着等待磁盘,此时操作系统可以将那个空闲的CPU核心用于服务其他线程。所以,由于线程总是在I/O上阻塞,我们可以让线程/连接数比CPU核心多一些,这样能够在同样的时间内完成更多的工作。

最终由PostgreSQL提供的计算数据库连接池中连接个数的公式为:

连接数 = ((核心数 * 2) + 有效磁盘数)

而至于更加具体的场景,还需要进一步的去考虑一些其他的因素。但是需要记住的一点那就是,连接数并不是越大越好,相反,过大的连接数反而会给数据库造成及其不必要的负担。

Golang中,数据库连接池是通过内部实现的连接池来实现的。连接的建立是惰性的,当你需要连接的时候,连接池会自动帮你创建。你不需要手动操作连接池,一切都由Golang来完成。 在Golang的标准库database/sql/sql.go中实现了数据库连接池。当我们使用sql.Open函数来创建连接时,实际上就是在使用连接池。例如,使用以下代码创建一个MySQL的连接池: db, err := sql.Open("mysql", "xxxx") 此外,我们还可以参考已经成熟并广泛使用的MySQL连接池库和Redis连接池库来了解连接池的实现方式。这些库通过实现连接池来提供更高效的数据库连接管理。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [使用mysql数据库与go进行交互](https://blog.csdn.net/tianlongtc/article/details/80115240)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* *3* [Golang连接池的几种实现案例](https://blog.csdn.net/asd1126163471/article/details/127020095)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值