本文记录了我在实际工作中关于数据库操作上一些小经验,也是新手入门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