Go语言学习 - database/sql

1,952 阅读10分钟

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, 里面的内容就是我们刚刚提到的, 包装好的链接对象
    • 里面的对象都是有效的, 没有关闭的, 随时用随时拿, 用完再放回来的
  • 为什么连接池不用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
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包是帮你生成查询语句的,很简单很方便