vlambda博客
学习文章列表

系统优化-连接池技术原理与实现


背景

在服务访问的过程中,每一次请求都要建立一次数据库连接。建立连接是一个费时的活动,每次都要花费大约0.05s~1s的时间,而且系统还要分配内存资源。这个时间对于一次或几次数据库操作,或许感觉不出系统有太大的开销。可是对于现在的web应用,存在许多高并发服务,同时有上千上万或更多的并发请求。在这种情况下,频繁的进行数据库连接操作势必占用很多的系统资源,网站的响应速度必定下降,严重的甚至会造成服务器的崩溃。

connect_pool_1

对于每一次数据库连接,使用完后都得断开。否则,如果程序出现异常而未能关闭,将会导致数据库系统中的内存泄露,最终将不得不重启数据库。还有,这种开发不能控制被创建的连接对象数,系统资源会被毫无顾忌的分配出去,如连接过多,也可能导致内存泄漏,服务器崩溃。

技术演进

背景中提到问题的根源就在于对数据库连接资源的低效管理。对于共享资源,有一个著名的设计模式:资源池设计模式。该模式正是为了解决资源的频繁分配、释放所造成的问题。为解决上述问题,可以采用数据库连接池技术「数据库连接池」的基本思想就是为数据库连接建立一个“缓冲池”。

系统优化-连接池技术原理与实现
connect_pool_2

「连接池基本原理」

  1. 服务启动时建立连接池对象
  2. 预先在缓冲池中放入一定数量的连接,当需要建立数据库连接时,只需从“缓冲池”中取出一个,使用完毕之后再放回去。省略创建连接和销毁连接的过程(TCP连接建立时的三次握手和销毁时的四次握手)
  3. 设定连接池最大连接数来防止系统无尽地与数据库连接。
  4. 通过连接池的管理机制监视数据库的连接的数量、使用情况,为系统开发、测试及性能调整提供依据。
  5. 访问服务完成,释放连接(此时的释放连接,并非真正关闭,而是将其放入空闲队列中。如实际空闲连接数大于初始空闲连接数则释放连接)
  6. 服务停止释放连接池对象

如何设计一个连接池

本文例子主要讲解golang数据库组件database/sql连接池的实现。

go_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() {
    ...
}

连接池使用技巧

  1. 连接池默认大小 defaultMaxIdleConns 连接池默认大小为 2 连接池太小会导致有太多方生方死的连接 maxIdleClosed 增长会很快

  2. 连接池状态 DBStats 定期获取 DBStats 了解连接池的基本信息

  3. 并发安全 连接池是并发安全的,但连接不是。

如果使用同一个连接,在一个事务里面,不要使用多个 goroutine 去操作这个连接。

  1. 连接失效 如果连接是客户端主动关闭的,那会在写包的时候返回 ErrBadConn,连接池会在重试次数内获取新的连接 如果连接是服务器主动关闭的,客户端并不知道,拿到连接后写包不会报错,但是在读服务器的 response 包 的时候会有 unexpected EOF 错误。4.1 设置 maxLifetime DB 定期清理连接池中的过期连接 如果没有设置 maxLifetime,表示连接池中的连接可以一直复用,如果服务器关闭了这条连接, 连接池是不知道的,返回给客户端的是一条已经关闭的连接。

获取数据库服务器的 wait_timeout,然后设置 maxLifetime 比这个数值小 10s 左右。

4.2 检查连接的有效性 MySQL 推荐在获取连接时、回池时、定期检查

优势

1. 资源重用

由于数据库连接得到重用,避免了频繁创建、释放连接引起的大量性能开销。在减少系统消耗的基础上, 另一方面也增进了系统运行环境的平稳性(减少内存碎片以及数据库临时进程/线程的数量)。

2. 更快的系统响应速度

数据库连接池在初始化过程中,往往已经创建了若干数据库连接置于池中备用。此时连接的初始化工作均已完成。对于业务请求处理而言,直接利用现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。

3. 新的资源分配手段

对于多应用共享同一数据库的系统而言,可在应用层通过数据库连接的配置,实现数据库连接池技术。设置某一应用最大可用数据库连接数的限制,避免某一应用独占所有数据库资源。

4. 统一的连接管理,避免数据库连接泄漏

在较为完备的数据库连接池实现中,可根据预先的连接占用超时设定,强制收回被占用连接。从而避免了常规数据库连接操作中可能出现的资源泄漏。

「感谢阅读」

往期推荐