database/sql标准库:直接操作数据库
database/sql标准库:直接操作数据库
摘要: 本篇作为模块四开篇,从database/sql标准库讲起,演示MySQL驱动的引入、连接池配置、CRUD操作和预处理语句,讲解事务的两种使用方式,分享rows.Close()忘记调用导致连接池耗尽的踩坑经历,对比database/sql、sqlx和GORM三种数据访问方式的优缺点。
开篇故事
模块三我们花了二十篇讲Web开发,从net/http到Gin框架,从参数绑定到中间件,搞定了HTTP层的一切。但Web服务有个绕不开的话题,数据从哪来?上一篇的repository层我写了个假数据直接返回,今天我们把这一层补上。
我第一次用Go操作数据库的时候,是从PHP转过来的。PHP那边用PDO或者ORM,写起来很丝滑。到了Go发现标准库database/sql的API有点反直觉,Query返回的Rows要手动遍历,每个字段要手动Scan,稍不留神就漏掉一个字段或者类型搞错。
但用熟了之后我发现database/sql设计得很克制,它把连接管理、连接池、预处理这些底层能力都封装好了,你只需要关注SQL本身。这篇我们从零开始,用database/sql完成一套完整的数据库操作。
一、连接数据库
先装MySQL驱动。database/sql本身只定义接口,具体实现由各数据库驱动提供。
packagemainimport("database/sql""fmt""log"// 下划线导入,注册MySQL驱动// 不直接调用包内函数,只执行init()_"github.com/go-sql-driver/mysql")funcmain(){// DSN格式: 用户名:密码@tcp(主机:端口)/数据库名?参数// parseTime让时间字段自动解析为time.Time// charset设置连接字符集dsn:="root:123456@tcp(127.0.0.1:3306)/testdb?charset=utf8mb4&parseTime=true&loc=Local"// sql.Open不会真正建立连接// 它只验证DSN格式是否正确db,err:=sql.Open("mysql",dsn)iferr!=nil{log.Fatal("打开数据库失败:",err)}// 程序退出时关闭连接池deferdb.Close()// Ping真正建立连接,验证配置// 必须调用,否则到第一次查询才发现连接问题iferr:=db.Ping();err!=nil{log.Fatal("连接数据库失败:",err)}fmt.Println("数据库连接成功")}有个细节容易忽略。sql.Open只是验证DSN格式,不会真正连接数据库。很多人以为Open成功了就万事大吉,结果到第一个查询才报错。养成习惯,Open之后调一次Ping。
二、连接池配置
database/sql自带连接池,这是它最大的优势之一。几个关键参数必须理解。
// 配置连接池,生产环境必调db.SetMaxOpenConns(25)// 最大连接数,默认无限制db.SetMaxIdleConns(10)// 最大空闲连接数,默认2db.SetConnMaxLifetime(5*time.Minute)// 连接最大存活时间db.SetConnMaxIdleTime(2*time.Minute)// 连接最大空闲时间MaxOpenConns要根据数据库配置来设。MySQL默认max_connections是151,如果你的服务有10个实例,每个实例设25,加起来250就已经超了。生产环境我一般设15到25,具体看数据库规格和并发量。
ConnMaxLifetime建议设短一些,比如5分钟。MySQL默认wait_timeout是8小时,但如果中间有防火墙或者负载均衡器,空闲连接可能被提前断开。设短一点可以避免使用已断开的连接。
三、CRUD操作
先建一张测试表,然后逐个演示增删改查。
// User 用户模型,字段与数据库列对应typeUserstruct{IDint64`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`Ageint`json:"age"`CreatedAt time.Time`json:"created_at"`}// CreateUser 插入数据funcCreateUser(db*sql.DB,user*User)(int64,error){// 使用占位符?防SQL注入// 占位符由驱动负责转义,安全result,err:=db.Exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)",user.Name,user.Email,user.Age,)iferr!=nil{return0,err}// LastInsertId获取自增主键IDid,err:=result.LastInsertId()iferr!=nil{return0,err}returnid,nil}// GetUserByID 查询单条记录funcGetUserByID(db*sql.DB,idint64)(*User,error){varuser User// QueryRow查询单行,Scan绑定到结构体// 注意Scan的字段顺序必须与SELECT一致err:=db.QueryRow("SELECT id, name, email, age, created_at FROM users WHERE id = ?",id,).Scan(&user.ID,&user.Name,&user.Email,&user.Age,&user.CreatedAt)// sql.ErrNoRows表示没查到记录iferr==sql.ErrNoRows{returnnil,fmt.Errorf("用户不存在")}iferr!=nil{returnnil,err}return&user,nil}// ListUsers 查询多条记录funcListUsers(db*sql.DB,limit,offsetint)([]User,error){rows,err:=db.Query("SELECT id, name, email, age, created_at FROM users LIMIT ? OFFSET ?",limit,offset,)iferr!=nil{returnnil,err}// 关键!defer关闭rows,否则连接泄漏// 这行必须在err检查之后第一时间写deferrows.Close()varusers[]User// Next遍历每一行forrows.Next(){varu User// Scan绑定当前行的字段值iferr:=rows.Scan(&u.ID,&u.Name,&u.Email,&u.Age,&u.CreatedAt);err!=nil{returnnil,err}users=append(users,u)}// 检查遍历过程中是否有错误// 比如连接断开导致中途失败iferr:=rows.Err();err!=nil{returnnil,err}returnusers,nil}// UpdateUser 更新数据funcUpdateUser(db*sql.DB,user*User)error{result,err:=db.Exec("UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?",user.Name,user.Email,user.Age,user.ID,)iferr!=nil{returnerr}// RowsAffected返回受影响的行数rows,_:=result.RowsAffected()ifrows==0{returnfmt.Errorf("没有更新任何行,用户可能不存在")}returnnil}// DeleteUser 删除数据funcDeleteUser(db*sql.DB,idint64)error{result,err:=db.Exec("DELETE FROM users WHERE id = ?",id)iferr!=nil{returnerr}rows,_:=result.RowsAffected()ifrows==0{returnfmt.Errorf("用户不存在")}returnnil}四、预处理与事务
预处理语句可以提升重复查询的性能,也能防SQL注入。
// BatchInsertUsers 预处理批量插入funcBatchInsertUsers(db*sql.DB,users[]User)error{// 准备语句,数据库会缓存执行计划// 重复执行同一SQL时性能提升明显stmt,err:=db.Prepare("INSERT INTO users (name, email, age) VALUES (?, ?, ?)",)iferr!=nil{returnerr}// 用完关闭,释放服务端资源deferstmt.Close()for_,u:=rangeusers{// 复用预编译的执行计划_,err:=stmt.Exec(u.Name,u.Email,u.Age)iferr!=nil{returnerr}}returnnil}// TransferMoney 事务操作,保证原子性funcTransferMoney(db*sql.DB,fromID,toIDint64,amountfloat64)error{// 开启事务tx,err:=db.Begin()iferr!=nil{returnerr}// defer中处理回滚// 已提交时调用Rollback是no-op,安全deferfunc(){iferr!=nil{tx.Rollback()}}()// 扣款,必须在tx上执行_,err=tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?",amount,fromID,)iferr!=nil{returnfmt.Errorf("扣款失败: %w",err)}// 加款,同样在tx上执行_,err=tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?",amount,toID,)iferr!=nil{returnfmt.Errorf("加款失败: %w",err)}// 全部成功,提交事务returntx.Commit()}五、独家踩坑:rows.Close()忘记调用
我在项目里踩过一个很隐蔽的坑。有个统计接口查大量数据,写法大概是这样。
// 错误写法,提前return导致rows未关闭funcGetStats(db*sql.DB)(int,error){rows,err:=db.Query("SELECT COUNT(*) FROM orders WHERE status = 'paid'")iferr!=nil{return0,err}// 忘了defer rows.Close()!if!rows.Next(){return0,fmt.Errorf("无数据")// 这里return了,rows永远不会被关闭// 连接被一直占用,不会被回收}varcountintrows.Scan(&count)returncount,nil}这个接口在测试环境跑没问题,上线后过了几个小时,突然所有数据库查询都超时。看监控发现连接池被打满了,所有连接都被占用,新请求拿不到连接就排队等待。
排查后发现就是这个rows没关闭。每调用一次接口泄漏一个连接,几个小时累积下来连接池就满了。Go的database/sql不会自动回收未关闭的Rows,必须手动调用Close。
正确做法是Query之后立刻写defer rows.Close(),放在err检查之后第一行。养成这个习惯,没有例外。
// 正确写法funcGetStatsFixed(db*sql.DB)(int,error){rows,err:=db.Query("SELECT COUNT(*) FROM orders WHERE status = 'paid'")iferr!=nil{return0,err}// 第一时间defer关闭,无论后续怎么return都会执行deferrows.Close()if!rows.Next(){return0,fmt.Errorf("无数据")}varcountintrows.Scan(&count)returncount,nil}六、对比分析
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| database/sql | 零依赖、控制力强 | 手动Scan、代码冗长 | 简单查询、性能敏感 |
| sqlx | 自动Scan到结构体 | 仍是手写SQL | 中小项目 |
| GORM | 全功能ORM、开发快 | 有性能开销 | 快速开发 |
database/sql代码量大但可控性最高,SQL怎么写执行就怎么跑。sqlx在database/sql基础上加了Scan到结构体的能力,代码量少很多。GORM直接操作对象不用写SQL,开发效率最高但黑盒也最重。下一篇我们就进入GORM,看ORM到底带来了什么。
总结
database/sql是Go操作数据库的基石,即使你后面用GORM,底层也是基于它。理解连接池、预处理、事务这些概念在哪都通用。记住两个原则,Open之后Ping验证连接,Query之后defer Close释放资源。模块四数据层之旅正式开始,下一篇GORM入门。
