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/sqlfmtlog// 下划线导入注册MySQL驱动// 不直接调用包内函数只执行init()_github.com/go-sql-driver/mysql)funcmain(){// DSN格式: 用户名:密码tcp(主机:端口)/数据库名?参数// parseTime让时间字段自动解析为time.Time// charset设置连接字符集dsn:root:123456tcp(127.0.0.1:3306)/testdb?charsetutf8mb4parseTimetruelocLocal// 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{IDint64json:idNamestringjson:nameEmailstringjson:emailAgeintjson:ageCreatedAt time.Timejson: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表示没查到记录iferrsql.ErrNoRows{returnnil,fmt.Errorf(用户不存在)}iferr!nil{returnnil,err}returnuser,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}usersappend(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()ifrows0{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()ifrows0{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上执行_,errtx.Exec(UPDATE accounts SET balance balance - ? WHERE id ?,amount,fromID,)iferr!nil{returnfmt.Errorf(扣款失败: %w,err)}// 加款同样在tx上执行_,errtx.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入门。