Introduction
我不太喜欢GORM, 感觉太复杂了(他一定很喜欢很擅长反射). 于是想去了解内置包是怎么用的, 这篇文章简单说说内置包是怎么做查询的. 一些细节诸如加锁以及buffer的使用, 没往下分析了. 先从一个简单的例子开始, 这是一个简单的查询:
cond := "SELECT name FROM users WHERE (pin=?)"
vals := []interface{}{"liangxiaohan"}
rows, err := db.Query(cond, vals...)
一次简单的查询大概经历了以下几个步骤, 在认识这些步骤的时候同时引入了一些问题:
- 想要获得一个连接对象, 可以是缓存的或新建出来的
- 什么是连接对象? 为什么需要缓存连接对象? 怎么去新建一个连接对象
- 建立一个Statement对象, 这个对象是与MySQL连接绑定在一起的
- 什么是Statement, 为什么需要Statement, 它发挥了什么作用
- 从这个对象出发, 查询结果, 并将结果转移到Rows对象中去
- Rows对象是什么, 怎么去读一个Rows, 过程是怎样的
连接对象
什么是连接对象
连接对象指的是go程序与数据库的连接, 我们把这个连接做成一个对象, 如果我们希望执行数据库操作, 我们拿着这个对象直接用就可以了, 避免了每次执行操作就临时创建连接这种比较浪费时间的操作.
database/sql包是一个笼统的包, 它是一个盖子, 是一个交互面. 它并在意底层到底是在链接何种数据库. 因此它只提供了"查询"/"执行"的interface等待别人实现. 举个例子, 假设现在你使用的是Mysql数据库, 那么这里的"查询",就是指针对"MySql数据库"的查询. 相对应的你创建的链接对象就是指针对"MYSQL数据库"的连接.
连接对象是个interface
// 连接对象
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}
我们可能会连接很多种数据库, MySQL,Postgres等等, 但是对于任何数据库我们也只是要它增删改查, 也就是说. 只要你能提供以上三个功能, 你就是一个合格的数据库链接对象:
- 准备出一个Statement: Statement(后文简称Stmt)我们后面会详细说, 简单概括, 他抽象出了一次数据库操作 , 可以直接用来执行的
- 开始一次Transaction
- 停止: 停止的内容可能是一次查询, 也可能是一次Transaction, 令这个链接对象失效.
interface的实现
再进一步的, 在你日常开发的过程中, 你需要执行数据库操作, 你引入了database/sql包来帮助你做数据库操作, 那么上面提到的interface是在哪儿实现的? 这时候你需要引入第三方包, 这个第三方包帮你实现了MySql数据库所有需要实现的接口. 于是你才能顺顺利利的执行MySql语句, 如下:
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
- 所有的接口都是在go-sql-driver/mysql中实现的, 你可以理解为它是一个驱动器
- database/sql是我们的交互界面(同样可以是GORM), 它负责管理工作,管理对话, 表明我们的需求, 我们需要查询什么东西. 真正负责去查的那还得是驱动器
因此这也造成了, 为了使用数据库, 必须一次性import两个库出来的现象, 当然这一次我们只去看看这个交互界面是怎么设计与实现的.
对于连接对象的包装与保护
type driverConn struct {
sync.Mutex // 保护下列字段
ci driver.Conn
closed bool
finalClosed bool
openStmt map[*driverStmt]bool
lastErr error
}
一个数据库链接对象, 本身不应该被用于并发操作(否则可能出现不一致的现象). 针对这个问题, 我们包装了链接对象产生了driverConn
结构体, 这个结构体里的主要属性包含有:
- 一个锁: 因为数据库链接对象是不能用于并发操作, 因此我们对它加上了一个锁, 用于保护它的安全
- ci: 链接对象本体
谈谈缓存机制
在我们尝试获得链接对象的时候, 我们都会指定一个"方式", 通常情况下这个方式是"cachedOrNewConn", 也就是来自缓存或者新建都可以, 当然有时候我们也会去指明我们需要一个新建的链接, 比如现有连接无效的情况下, 我们就需要明确的要求它去创建一个新的链接, 我们明确几个问题:
- 连接池的本质是什么
- 每个数据库对象(*DB)都有一个自己的连接池, 它本质上是一个数组,
[]*driverConn
, 里面的内容就是我们刚刚提到的, 包装好的链接对象 - 里面的对象都是有效的, 没有关闭的, 随时用随时拿, 用完再放回来的
- 每个数据库对象(*DB)都有一个自己的连接池, 它本质上是一个数组,
- 为什么连接池不用Sync.Pool
- 这个问题我们在Sync.Pool里就提到了, pool在每次GC的时候都会清空一次, 然后重新往pool里补充数据. 如果一个对象它存在"有效/无效/有状态/无状态"的说法, 那么它就不适合存在于Pool里
- 为什么我们需要保持一个连接池
- 不用反复创建, 比较方便, 想用就用也比较快
- 有了连接池方便统一管理连接对象, 对于任何超时占用的链接能有效回收数据, 避免了资源泄露
- 连接池里的连接对象从哪儿来, 怎么补充
- 在查询之前, 索取连接对象的时候, 假设现在我们需要创建一个新的连接对象, 这个时候我们会考虑往连接池里补充连接对象
- (稍复杂可跳过) 这里涉及一部分异步编程的内容, 创建新链接对象的过程涉及与数据库链接的通信, 等待通信的这个过程是异步的, 因此这里也同样引入了select+ 超时的机制(通过context包完成), 在有限的时间内, 我们尝试去读取返还回来的连接对象, 如果对象是有效的(err==nil)那么就会放到连接池里
- 为什么连接对象可能会失效, 失效怎么办
- 连接对象有两种可能会失效, 一种是超时, 数据库中设定任何连接对象都会有一个lifetime的概念, 时间一到就算无效
- 如果数据库连接已经关闭, 那么个中的所有连接对象都是无效的
Statement
什么是Statement
Stmt就是经过初步加工的SQL语句.在你的SQL被查询之前, 它会被先解析成一个Statement(后面用Stmt代替). 因为日常接触不到, 因此相对也会比较抽象. 在SQL查询语句执行之前会需要先解析查询语句, 随后生成执行语句. 随后执行计划.
为什么需要使用Stmt
从这一点出发, 如果我们能将一些常用查询语句制作成stmt, 下次查询的时候不要使用SQL而去使用stmt这样会不会节省一些时间, 有人做了一个简单的实验佐证这一点: stmt对比db.Query. 这个人给的答案看起来很好, 他在实验最后给了一个结论: stmt能节省我们SQL查询的时间, 但是这个人的实验设计的有一些问题, 我们稍后会解释, stmt是不是一个万能的答案, 以及这个实验问题出在哪儿
Stmt的生成与使用
我们一步一步开始说, 因为在transaction下以及常规模式下, stmt的表现不一致, 我们只说常规模式(db.Query)下, stmt是怎么生成的, 接下来又是怎么使用的
stmt,err := db.Prepare(<sql_string>)
这是你生成stmt的过程, Prepare函数会涉及sql语句的解析, 并将解析发现的错误返回到err里, 这里的错误可能是: "Unknown column name"等等错误, 也就是说这一步是会链接数据库的
- 首先,我们尝试获得一个连接对象, 将这个连接对象带下去
- 调用MySql驱动器中定义的解析函数, 将查询语句解析出来, 生成一个裸的stmt
- 将上面带来的连接对象赋给这个裸的stmt对象, 生成了能直接用的Stmt对象. 在这里我们能发现, 生成的Stmt对象里包含一个连接对象的.
等到我们使用stmt对象的时候, 我们会先尝试stmt中的那个连接对象是否是有效的, 是否是繁忙的, 如果有效且空闲的情况下, 我们才会去使用这个stmt对象. 但是如果现在这个链接对象是繁忙的, 我们就需要重新建立一个stmt对象, 使用一个空闲的连接对象.正是考虑到这一点, 在流量很大非常繁忙的时候, 你手上大部分的连接对象都是繁忙的, 这种时候你以为你会复用你的stmt对象, 但是实际上这些对象都需要被重建. 因此也不见得效率一定就是高
回到问题的开头, 这个人设计的实验可能有什么问题, 就是stmt的会在高并发/连接池紧张的情况下, 效率逐渐下降, 而这个人设计的实验是串行的, 也就是说应该不太会没有连接对象用, 因此没有表现出stmt效率下降的一面
Rows
Rows对象指你查询的结果, 但是查询出来的结果并不在Rows内. 而是存在连接对象的buffer内, 我们需要"遍历"这个buffer, 将我们想要的数据取回来
rows, err := db.Query(cond, vals...)
defer rows.Close()
for rows.Next() {
var name string
rows.Scan(&name)
}
上面是一个简单的查询示例, 这里面经历的步骤大致有:
- 从数据库中请求数据, 并将结果存到Rows里
- 拿着之前生成好的Stmt, 前往数据库查询. 查出来最原始的数据会存在连接对象的buffer里. 我理解这样做的目的是因为buffer可能会比较大, 我们暂时不要一次性读完它. 我们就把它放这儿, 随后遍历它一次读一点就好. 压力会小一点. 接着我们创建一个新的Rows对象, 将这个连接对象赋给这个rows
- 每次读一行回来, 存起来
- 每次Next的时候我们拿着Rows中的连接对象, 从buffer中读取一行数据, 将数据转存到Rows中的
lastcols
对象中. 这个对象只能存一行数据, 代表每次读的内容, 本质上是一个[]interface{}
- 每次Next的时候我们拿着Rows中的连接对象, 从buffer中读取一行数据, 将数据转存到Rows中的
- 将这一行扫描到数据结构里, 重复这个步骤直到Next()读不出任何数据后, 关闭Rows
func(*scope) pluck(..) {
...
defer rows.Close()
for rows.Next() {
elem := reflect.New(dest.Type().Elem()).Interface()
scope.Err(rows.Scan(elem))
dest.Set(reflect.Append(dest, reflect.ValueOf(elem).Elem()))
}
...
}
而GORM中扫描数据的方式本质上也是包装了这一个步骤, 上面的pluck(一种查询函数)其核心也是将数据读到buffer里, 然后通过Next()
的方式遍历并存放到目标结构体里(只不过用了一大堆reflect)
一个安利
database/sql已经足够简洁了,但是还是感觉直接使用这个包不太行, 有一个小助手我想推荐一下didi-gendry, 简单介绍一下其中的builder包是帮你生成查询语句的,很简单很方便