背景
在服务访问的过程中,每一次请求都要建立一次数据库连接。建立连接是一个费时的活动,每次都要花费大约0.05s~1s的时间,而且系统还要分配内存资源。这个时间对于一次或几次数据库操作,或许感觉不出系统有太大的开销。可是对于现在的web应用,存在许多高并发服务,同时有上千上万或更多的并发请求。在这种情况下,频繁的进行数据库连接操作势必占用很多的系统资源,网站的响应速度必定下降,严重的甚至会造成服务器的崩溃。
connect_pool_1
对于每一次数据库连接,使用完后都得断开。否则,如果程序出现异常而未能关闭,将会导致数据库系统中的内存泄露,最终将不得不重启数据库。还有,这种开发不能控制被创建的连接对象数,系统资源会被毫无顾忌的分配出去,如连接过多,也可能导致内存泄漏,服务器崩溃。
技术演进
背景中提到问题的根源就在于对数据库连接资源的低效管理。对于共享资源,有一个著名的设计模式:资源池设计模式。该模式正是为了解决资源的频繁分配、释放所造成的问题。为解决上述问题,可以采用数据库连接池技术
。「数据库连接池」的基本思想就是为数据库连接建立一个“缓冲池”。
connect_pool_2
「连接池基本原理」
- 服务启动时建立连接池对象
- 预先在缓冲池中放入一定数量的连接,当需要建立数据库连接时,只需从“缓冲池”中取出一个,使用完毕之后再放回去。省略创建连接和销毁连接的过程(TCP连接建立时的三次握手和销毁时的四次握手)
- 设定连接池最大连接数来防止系统无尽地与数据库连接。
- 通过连接池的管理机制监视数据库的连接的数量、使用情况,为系统开发、测试及性能调整提供依据。
- 访问服务完成,释放连接(此时的释放连接,并非真正关闭,而是将其放入空闲队列中。如实际空闲连接数大于初始空闲连接数则释放连接)
- 服务停止释放连接池对象
如何设计一个连接池
本文例子主要讲解golang数据库组件database/sql
连接池的实现。
go_sql
上图为go sql官方实现逻辑, 源码地址 https://github.com/golang/go/tree/master/src/database/sql
$ tree $GOROOT/src/database/sql
├── convert.go # scan row
├── convert_test.go
├── ctxutil.go # 判断 ctx,然后执行 prepare/exec/query/close 等操作
├── doc.txt
├── driver
│ ├── driver.go # 定义了实现数据库驱动所需的接口,由 sql 包和具体的驱动包来实现
│ ├── types.go # 数据类型的别名和转换
│ └── types_test.go
├── example_cli_test.go
├── example_service_test.go
├── example_test.go
├── fakedb_test.go
├── sql.go # 关于 SQL 数据库的一些通用接口和类型,包括:连接池、数据类型、连接、事务、状态
└── sql_test.go
DB对象结构
type DB struct {
// Atomic access only. At top of struct to prevent mis-alignment
// on 32-bit platforms. Of type time.Duration.
waitDuration int64 // 等待新连接的总时间,用于统计
connector driver.Connector // 由数据库驱动实现的连接器
// numClosed is an atomic counter which represents a total number of
// closed connections. Stmt.openStmt checks it before cleaning closed
// connections in Stmt.css.
numClosed uint64 // 关闭的连接数
mu sync.Mutex // 锁
freeConn []*driverConn // 可用连接池
connRequests map[uint64]chan connRequest // 连接请求表,key 是分配的自增键
nextRequest uint64 // 连接请求的自增键
numOpen int // 已经打开 + 即将打开的连接数
// Used to signal the need for new connections
// a goroutine running connectionOpener() reads on this chan and
// maybeOpenNewConnections sends on the chan (one send per needed connection)
// It is closed during db.Close(). The close tells the connectionOpener
// goroutine to exit.
openerCh chan struct{} // 告知 connectionOpener 需要新的连接
resetterCh chan *driverConn // connectionResetter 函数,连接放回连接池的时候会用到
closed bool
dep map[finalCloser]depSet
lastPut map[*driverConn]string // debug 时使用,记录上一个放回的连接
maxIdle int // 连接池大小,默认大小为 2,<= 0 时不使用连接池
maxOpen int // 最大打开的连接数,<= 0 不限制
maxLifetime time.Duration // 一个连接可以被重用的最大时限,也就是它在连接池中的最大存活时间,0 表示可以一直重用
cleanerCh chan struct{} // 告知 connectionCleaner 清理连接
waitCount int64 // 等待的连接总数
maxIdleClosed int64 // 释放连接时,因为连接池已满而被关闭的连接总数
maxLifetimeClosed int64 // 因为超过存活时间而被关闭的连接总数
stop func() // stop cancels the connection opener and the session resetter.
}
driverConn 对象结构
// driverConn wraps a driver.Conn with a mutex, to
// be held during all calls into the Conn. (including any calls onto
// interfaces returned via that Conn, such as calls on Tx, Stmt,
// Result, Rows)
type driverConn struct {
db *DB // 数据库句柄
createdAt time.Time
sync.Mutex // 锁
ci driver.Conn // 对应具体的连接
closed bool // 是否标记关闭
finalClosed bool // 是否最终关闭
openStmt map[*driverStmt]bool // 在这个连接上打开的状态
lastErr error // connectionResetter 的返回结果
// guarded by db.mu
inUse bool // 连接是否占用
onPut []func() // 连接归还时要运行的函数,在 noteUnusedDriverStatement 添加
dbmuClosed bool // 和 closed 状态一致,但是由锁保护,用于 removeClosedStmtLocked
}
获取连接
代码地址如下
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error) {
...
}
https://github.com/golang/go/blob/master/src/database/sql/sql.go#L1131-L1246
从连接池中获取连接时首先要对整个连接池加锁,如果连接池已经事先被关掉了,直接返回 errDBClosed 错误。如果连接池无恙,将会评估连接请求是否取消或过期。
尽可能优先使用空闲的连接而不是新建一条连接(这也是连接池存在的意义)。看一下是否还剩下空闲连接,如果还有余粮,就取第 0 条连接出来, 然后左移所有连接填补空位。这里对连接本身操作都会上锁。
如果没有空闲连接了,而且已打开的 + 即将打开的连接数超过了限定的最大打开的连接数,就要发送一条连接请求然后排队(不会新建连接)。等待排队期间同时监听连接请求是否取消或过期,如果此时连接被取消很不巧正好有连接来了,就将连接放回连接池中;如果等着等着连接来了, 会先检查这个连接的上一次会话是否被重置(擦屁股),确认没问题就用这条连接。
如果还没到限定的最大打开的连接数,会新建一个连接,代码在https://github.com/golang/go/blob/574c286607015297e35b7c02c793038fd827e59b/src/database/sql/sql.go#L1031-L1047
// Assumes db.mu is locked.
// If there are connRequests and the connection limit hasn't been reached,
// then tell the connectionOpener to open new connections.
// 如果有连接请求,并且没有达到连接数的限制,告知 connectionOpener 打开新的连接
func (db *DB) maybeOpenNewConnections() {
...
}
连接池使用技巧
- 连接池默认大小 defaultMaxIdleConns
连接池默认大小为 2
连接池太小会导致有太多方生方死的连接
maxIdleClosed 增长会很快
- 连接池状态 DBStats
定期获取 DBStats 了解连接池的基本信息
- 并发安全
连接池是并发安全的,但连接不是。
如果使用同一个连接,在一个事务里面,不要使用多个 goroutine 去操作这个连接。
- 连接失效 如果连接是客户端主动关闭的,那会在写包的时候返回 ErrBadConn,连接池会在重试次数内获取新的连接 如果连接是服务器主动关闭的,客户端并不知道,拿到连接后写包不会报错,但是在读服务器的 response 包 的时候会有 unexpected EOF 错误。4.1 设置 maxLifetime DB 定期清理连接池中的过期连接 如果没有设置 maxLifetime,表示连接池中的连接可以一直复用,如果服务器关闭了这条连接, 连接池是不知道的,返回给客户端的是一条已经关闭的连接。
获取数据库服务器的 wait_timeout,然后设置 maxLifetime 比这个数值小 10s 左右。
4.2 检查连接的有效性 MySQL 推荐在获取连接时、回池时、定期检查
优势
1. 资源重用
由于数据库连接得到重用,避免了频繁创建、释放连接引起的大量性能开销。在减少系统消耗的基础上, 另一方面也增进了系统运行环境的平稳性(减少内存碎片以及数据库临时进程/线程的数量)。
2. 更快的系统响应速度
数据库连接池在初始化过程中,往往已经创建了若干数据库连接置于池中备用。此时连接的初始化工作均已完成。对于业务请求处理而言,直接利用现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。
3. 新的资源分配手段
对于多应用共享同一数据库的系统而言,可在应用层通过数据库连接的配置,实现数据库连接池技术。设置某一应用最大可用数据库连接数的限制,避免某一应用独占所有数据库资源。
4. 统一的连接管理,避免数据库连接泄漏
在较为完备的数据库连接池实现中,可根据预先的连接占用超时设定,强制收回被占用连接。从而避免了常规数据库连接操作中可能出现的资源泄漏。