本文记录了我在实际工作中关于数据库操作上一些小经验,也是新手入门golang时我认为一定会碰到问题,没有什么高大上的东西,所以希望能抛砖引玉,也算是对这个问题的一次总结。

访问数据库

相信大家第一次碰到这个问题的时候应该和我一样,去网上找个例子参考一下。没错,这样的例子一搜一大把,于是我们很容易写了如下一段代码:

import (
    "fmt"
	"database/sql"
	_ "github.com/go-sql-driver/mysql"
)

func main() {
	db, err := sql.Open("mysql","root:111111@tcp(127.0.0.1:3306)/testdb")
	if err != nil {
		panic(err)
	}
	err = db.Ping()
    if err != nil {
	    panic(err)
    }
    fmt.Println("Successfully connected!")
}

把程序运行起来一看,成功地输出了想看到的东西,内心一阵暗喜“so easy"。于是把这段代码封装成一个公共方法供其他地方调用:

func GetDbContext() *sql.DB {
    db, err := sql.Open("mysql","root:111111@tcp(127.0.0.1:3306)/testdb")
	if err != nil {
		panic(err)
	}
	err = db.Ping()
    if err != nil {
	    panic(err)
    }
    return db
}

func DoSomething(){
    db := GetDbContext()
    rows,_ := db.Query("select * from table1")
}

没错我最早就是这么干的,然后开始愉快地转头写CRUD了,不过事情可没这么简单。

很快, 编码五分钟捉虫两小时开场了。

慢慢的我就发现,在连续多次操作数据库后就偶尔发生程序卡死的情况,请求一直是pending状态,只能杀死进程重启才可以。刚开始没在意,也没有怀疑是数据库操作有问题,但后来越来越频繁严重影响到程序开发,没办法就记log加断点调试看是哪里出了问题。经过反复验证后确定问题就出在执行SQL语句这里,这下懵了,我看网上大家都是这么写的怎么会有问题??

连接池问题

根据多年开发经验,大胆猜测SQL执行失败最大的可能性就是数据库连接不上,在确认数据库没有崩掉的情况下开始研究代码哪里写的不对,但是前后也就那么几行代码实在看不出什么毛病,只能开始深入了研究database/sql包的知识点。

通过查资料发现open完数据库后的返回对象sql.DB实际上是一个连接池对象,并不是单纯的某一个连接。它是一个抽象的数据访问接口,和数据库类型无关,当然也就和具体的数据库Schema无关。我们要实现某一个数据库的访问单纯用这个包是不够的,还要引入具体的数据库驱动包,这个驱动才是真正实现数据库访问的东西。

现在再回过头来看代码,既然open创建了连接池,那用完把它销毁不就好了,于是参考官网文档稍加改进:

func GetDbContext() *sql.DB {
    db, err := sql.Open("mysql","root:111111@tcp(127.0.0.1:3306)/testdb")
	if err != nil {
		panic(err)
	}
	err = db.Ping()
    if err != nil {
	    panic(err)
    }
    return db
}

func DoSomething(){
    db := GetDbContext()
    defer db.Close()
    rows,_ := db.Query("select * from table1")
}

看似行得通,但是估计没人愿意这样做。原因很明显,别的先不谈,创建和销毁连接池开销太大了,你这样对它于心何忍,拿着屠龙刀去砍柴。

使用连接池的好处就是不需要开发者频繁地创建和销毁连接,这两项工作都交给了连接池去做,我们只需要在使用前找它要一个可用的连接,用完还回去就可以了。

这里引用一段官方文档中的原话:

核心意思就是sql.DB是一个长生命周期对象,你不要随便打开和关闭,并且建议你在程序中为每一个数据库创建唯一的sql.DB

那么现在的问题就是如何保证程序中只有一个连接池呢?

很简单,使用一个全局变量即可,有点类似C#和java中static的味道,在Golang中可以使用如下方法声明一个全局对象:

package demo

import (
	"database/sql"
)

var mydb,_ =  sql.Open("mysql","connection_string")

不过我们的业务场景比较特殊,系统中有很多个数据库,要根据不同参数去连不同数据库,那么上面这种声明赋值方式就不行了,我稍加改进,结合map实现了连接池动态管理:

var envdbMap map[string]*sql.DB

func GetEnvDbContext(connector config.DbConnector) *sql.DB {
	if envdbMap == nil {
		envdbMap = make(map[string]*sql.DB)
	}

	db, ok := envdbMap[connector.ID]
	if ok {
		return db
	} else {
		connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", connector.Host, connector.Port, connector.UserName, connector.Password, connector.DatabaseName)
		db, err := sql.Open("postgres", connStr)

		envdbMap[connector.ID] = db
		return db
	}
}

原理很简单,就是用map装池子,池子装连接。

有借有还

到这里连接池已经准备好了,那么如何从池子中取一个可用的连接呢?这点池子已经帮大家考虑的很周到了,大家不需要写额外代码去获取连接,直接拿起池子用就可以了,内部会有一系列机制帮你弄到一个连接去执行SQL,以后有机会对池子的原理来做个解析。

但是用完要记得还回去,这个必须你手动去做,例如:

rows,_ := db.Query("select * from table1")
defer rows.Close()
// do sth...

最好不要在do sth之后做Close,因为一旦你这个过程中发生异常,导致后面的Close无法执行,那么这个连接就一直被占用,日积月累TCP连接就被你耗光了。

官方文档说了,rows.Close()是一种无害(harmless)操作,你可以做多次,但是不能忘了做。

这里有个特殊情况要注意,对于那种没有返回结果的SQL语句,千万不要使用Query方法去执行,这会导致无法回收连接,这时候推荐使用Exec方法去执行。

配置连接池

默认情况下连接池没有数量限制,但是我们的机器有TCP的数量限制,不要因为一个程序拖死一台机器,所以不推荐无限量的去使用。database/sql包提供了几个连接池配置参数,主要包含:

  • db.SetMaxIdleConns(N) 设置空闲连接的数量
  • db.SetMaxOpenConns(N) 设置打开的连接数量
  • db.SetConnMaxLifetime(duration) 设置连接的生存时间
    详细的介绍大家可以参考官方文档。

总结

经过以上分析,可以清晰的知道最开始的bug就是因为错误地使用了连接池导致数据库连接被耗光从而无法执行SQL语句,其实说简单也很简单。

以上就是工作中使用golang访问数据库的踩坑历程,希望能帮到新接触golang的朋友,如有错误的地方欢迎指出,以免误导他人。

参考连接

http://go-database-sql.org/accessing.html

http://go-database-sql.org/retrieving.html

http://go-database-sql.org/connection-pool.html

01-29 23:27