偶然看见golang之database/sql与go-sql-driver这篇博客,让我有了阅读学习go数据库访问接口和mysql驱动实现的源码的想法和动力。
因为刚好工作负责的组件中就是需要使用mysql,之前就出现过一些问题,但是因为当时对
go database/sql
和go-sql-driver/mysql
的源码一点都不熟悉,只会用几个api,对于问题出现的原因一头雾水,希望通过阅读学习源码提升能力的同时,以后出现问题可以更加清楚应该怎么解决。
前言
database/sql
主要是提供了对数据库的读写操作的接口和数据库连接池功能,需要向其注册相应的数据库驱动才能工作。
go-sql-driver/mysql
是mysql数据库驱动,mysql客户端的真正实现,负责与mysql服务端进行交互,完成连接、读写操作。
本文源码版本如下:database/sql
: go1.10.3go-sql-driver/mysql
: v1.4
获取数据库对象
引用golang之database/sql与go-sql-driver中的例子
1 | package main |
通过调用sql.Open
来获取一个数据库对象db,然后就可以用这个数据库对象来对数据进行操作了。
直接来看一下sql.Open
源码里做了些什么
1 | //database/sql/sql.go |
sql.Open
的工作主要是根据driverName获取相应的数据库驱动对象,然后调用 sql.OpenDB
返回数据库对象。
sql.OpenDB
的工作是创建数据库对象,同时开启两个协程connectionOpener
和connectionResetter
,connectionOpener
负责收到请求后新建连接,connectionResetter
负责异步重置连接,具体详细的信息后面有机会再说。
数据库驱动注册
这个时候应该都会好奇有疑问,drivers这个map的值是什么时候设置的,上面也说了需要向database/sql注册相应的数据库驱动才能工作,但是从上面的例子没有看出哪里有进行数据库驱动注册的操作。
打开github.com/go-sql-driver/mysql/driver.go
找到init
函数,可以发现,原来在导入github.com/go-sql-driver/mysql
之后,这个库就自动(偷偷)注册一个mysql数据库驱动到sql.drivers
1 | //github.com/go-sql-driver/mysql/driver.go |
连接池
sql.Open
只是创建了数据库对象(简称:db)以及开启了两个协程,并没有连接数据库的操作,但是直接调用DB.Query
就可以进行数据库查询了,这是因为database/sql
实现了数据库连接池功能,直接调用DB.Query
,会获取一个可用的数据库连接(新建或从连接池中获取),然后调用数据库驱动的接口进行数据库操作。
DB.conn
DB.conn
是db内部获取数据库连接的函数
1 | //database/sql/sql.go |
DB.conn
获取连接步骤可以总结为3步:
- 如果strategy是cachedOrNewConn,优先从空闲队列中获取
- 如果strategy不是cachedOrNewConn,或者空闲队列为空,则先检查最大打开连接数和正在打开连接数,如果正在打开连接数已经到达了限制,创建一个获取连接请求并等待。
- 如果正在打开连接数还没有到达限制,直接创建一个新连接。
创建新连接是调用db.connector.Connect
进行创建的,先来回顾一下,db.connector
是什么,是怎么来的:
1 | //database/sql/sql.go |
查看go-sql-driver/mysql
的源码发现,mysql驱动没有实现driver.DriverContext
接口,因此实际是创建了dsnConnector
1 | //database/sql/sql.go |
dsnConnector.Connect
调用驱动的Open
函数来获取一个数据库驱动对象。
driverConn.releaseConn、 DB.PutConn、DB.putConnDBLocked
1 | //database/sql/sql.go |
driverConn.releaseConn
函数的作用是释放连接,一般是在数据库操作完成或者数据库事务结束后调用的。
driverConn.releaseConn
调用DB.PutConn
,DB.PutConn
调用DB.putConnDBLocked
DB.putConnDBLocked
负责丢弃连接、将连接返回给等待的协程或放到空闲队列:
- 如果正在打开连接数已经到达限制,丢弃连接,返回fasle
- 如果
db.connRequests
里存在连接请求,则取出其中一个请求,把连接通过channel返回给该请求的协程 - 如果
db.connRequests
里没有连接请求,则检查空闲连接数有没有达到限制,如果没有,就将该连接放到空闲连接队列中,否则丢弃连接,返回false。
DB.maybeOpenNewConnections、 DB.connectionOpener
1 | //database/sql/sql.go |
DB.maybeOpenNewConnections
根据目前的连接请求数量和目前还能打开的连接数量判断是否发送创建连接信号给协程connectionOpener
connectionOpener
就是在DB.Open
时开启的一个协程,就是用于在出现异常情况的情况下(比如释放的连接是坏连接),进行异步的创建连接。
1 | //database/sql/sql.go |
connectionOpener
协程循环监听db.openerCh
管道,收到消息就调用DB.openNewConnection
创建一个新连接,再通过DB.putConnDBLocked
把连接返回给等待的协程。
总结
database/sql
没有专门定义一个连接池结构来实现连接池,连接池的功能融合在了数据库对象DB
中
这里直接引用golang之database/sql与go-sql-driver中对连接池的总结:
- 当某个goroutine需要一个连接的时候,首先查看空闲连接数组中是否有可用连接,如果有,则直接从空闲数组中获取;如果空闲数组中没有可用的连接,则需要判断当前打开的连接数是否超出设定的最大值,如果是,则当前goroutine阻塞;如果当前打开的连接数并没有大于设定的最大值,则直接生产一个连接返回;
- 当某个goroutine结束数据库操作时,将当前使用的连接放入空闲连接数组中,这时需要进行判断,是否有某个goroutine阻塞在获取连接上,如果有,则将当前的连接直接返回给阻塞的goroutine,如果没有goroutine阻塞在获取连接上,则可以直接放入空闲连接数组即可;
另外,下面整理出DB
类中主要用来实现连接池的函数:
DB.conn
:内部获取连接的函数,“连接池”获取连接的入口。driverConn.releaseConn
:调用DB.PutConn
释放连接,一般在数据库操作完成或事务结束后被调用。DB.PutConn
:进行一些处理,调用DB.putConnDBLocked
。DB.putConnDBLocked
:丢弃连接、返回连接给等待的协程或放到空闲队列。DB.maybeOpenNewConnections
:根据目前的连接请求数量和目前还能打开的连接数量判断是否发送创建连接信号给协程connectionOpener
。DB.connectionOpener
:负责异步创建连接。
数据库操作
DB.Query & DB.QueryContext
1 | //database/sql/sql.go |
DB.Query
其实是调用DB.QueryContext
,自动传入一个空的context,因此DB.Query
无法通过context设置超时或提前取消。DB.QueryContext
调用db.conn
来获取连接,获取连接成功后,调用驱动相应的接口进行查询操作:
- 如果驱动有实现QueryerContext或者Queryer接口,则直接执行驱动的相应接口
- 如果如果驱动没有实现QueryerContext或者Queryer接口,又或者返回了driver.ErrSkip错误,则采取先预编译查询语句再传参进行查询的方法
- 观察31行代码,调用
DB.queryDC
时传入了db.releaseConn
函数,就是为查询操作结束或者发生异常情况释放连接做准备的。
1 | //database/sql/sql.go |
rows.initContextClose
函数会开启一个协程,阻塞监听context的Done事件消息,若发生context超时或cancel事件,调用rows.close
- 第4行代码,把cancel函数传给rows,使得没有发生context超时或cancel时,
rows.close
能调用cancel函数结束awaitDone
协程
Rows.Next & Rows.NextResultSet & Rows.Scan & Rows.Close
DB.Query
和DBQueryContext
接口返回的是Rows
对象,还需要调用Rows.Next
、Rows.NextResultSet
和Rows.Scan
函数进行数据的读取,如下所示:
1 | rows, _ := db.Query() |
1 | //database/sql/sql.go |
Rows.nextLocked
返回两个值,一个是doClose,一个是ok;doClose表示连接是否关闭了,ok表示是否有读取到一行数据:
- 如果正常读到一行数据,则返回doClose=false,ok=true
- 如果读数据返回错误,并且错误不是
io.EOF
,则返回doClose=true,ok=false - 如果返回的错误是
io.EOF
,表示当前结果集的数据已经读取完,这个时候需要检查该连接中是否还存在其他的结果集数据,如果还有,返回doClose=false,ok=false;如果没有,返回doClose=true,ok=false。
Rows.Next
调用Rows.nextLocked
,根据其返回的doClose判断是否调用Rows.close
。注意,Rows.Next
返回false只代表已经读取完当前数据集的数据,不代表该次查询返回的数据已经读取完,不代表Rows.Next
会自动调用Rows.close
;如果该次查询是多语句查询,返回了多个结果集而且连接的socket上还有结果集的数据的话,Rows.nextLocked
返回的doClose为false,Rows.Next
就不会调用Rows.close
。可以通过调用Rows.NextResultSet
判断是否还存在下一个数据集数据并获取下一个数据集结果。
1 | //database/sql/sql.go |
Rows.Scan
的工作是将在Rows.Next
中读取到的保存到rs.lastcols数据进行格式转换赋值给传入的指针参数
1 | //database/sql/sql.go |
Rows.close
调用驱动的rowsi.Close
,驱动的rowsi.Close
会将该次查询的未读取完的数据都读取完并丢弃。rowsCloseHook
是给内部测试用的钩子函数。Rows.close
还会调用rs.cancle
结束Rows.awaitDone
协程。Rows.close
最后会调用传入的dc.releaseConn
函数来释放连接。
总结:
- 正常情况下循环调用
Rows.Next
和Rows.Scan
直至所有数据读取完成(如果是多结果集查询,还需要调用Rows.NextResultSet
),Rows.Next
会自动调用Rows.Close
。 - 如果发生了错误或异常,数据没有读取完成,务必调用
Rows.Close
。如果数据没有读取完成,又没有调用Rows.Close
,可能会导致连接损坏或者连接一直不能被释放,关于这一部分的分析,需要在go-sql-driver/mysql
篇中结合驱动源码实现来进行具体分析。
DB.Begin & DB.BeginTx
1 | //database/sql/sql.go |
- 与上面的
DB.Query
&DB.QueryContext
相同,DB.Begin
调用DB.BeginTx
,传入一个空的context,这里就不再把代码贴出来。 DB.BeginContext
实际调用DB.begin
和DB.beginDC
,注意在tx
结构中会保存连接对象dc
,以后使用tx
对象的接口进行数据库操作时,都使用该连接。tx.awaitDone
的作用和套路跟rows.awatiDone
类似,监听context的Done事件消息,在context超时时,调用tx.rollback进行事务回滚;context的cancel函数传给tx
,在tx.Commit
或tx.Rollback
时调用,结束tx.awaitDone
协程。
DB.PrepareContext、TX.PrepareContext、Conn.PrepareContext
1 | func (tx *Tx) PrepareContext(ctx context.Context, query string) (*Stmt, error) { |
DB.PrepareContext
、TX.PrepareContext
、Conn.PrepareContext
最终都是调用DB.prepareDC
。Conn
是数据库连接对象,通过DB.Conn
获取,Conn
也有QueryContext、ExecuteContext、PrepareContext等数据库操作接口。TX
和Conn
都实现了stmtConnGrabber
接口,因此TX
和Conn
在调用DB.prepareDC
时都把自身赋值给cg参数,而DB
则是把cg参数设为nil,记住这一点,接下来看Stmt.connStmt
函数
1 | // 使用Stmt进行数据库操作时,都需要先调用connStmt获取一个数据库连接 |
TX
和Conn
都保存了一个连接,进行数据库操作时都是用同一个连接,而DB
进行数据库操作则是每次从连接池中获取一个连接,如果该连接没有进行过该查询/执行语句的Prepare操作的话,需要先进行Prepare操作。因此使用预编译应该使用TX
或者Conn
,使用DB
的接口来进行预编译操作的话,必须是一个很大批量操作才能起到理论上的加速作用,不然反而会增加与数据库服务器之间的通信交互。