1. 概述与设计哲学
database/sql 是 Go 标准库提供的 SQL(或类 SQL)数据库通用接口包。它自 Go 1.0 起进入标准库,包文档第一句话就点明了它的定位:
Package sql provides a generic interface around SQL (or SQL-like) databases.The sql package must be used in conjunction with a database driver.(sql 包必须与数据库驱动配合使用)
1.1 三层设计
Go
┌─────────────────────────────────────────────┐
│ 用户业务代码 │
├─────────────────────────────────────────────┤
│ database/sql ← 本文档 │
│ · 通用 API(DB/Tx/Stmt/Rows...) │
│ · 连接池管理、并发安全、重试 │
│ · 参数/结果类型转换(convert.go) │
├─────────────────────────────────────────────┤
│ database/sql/driver ← 驱动必须实现的接口 │
│ driver.Conn / Stmt / Rows / Tx ... │
├─────────────────────────────────────────────┤
│ 第三方驱动(mysql、pq、pgx、sqlite...) │
│ 通过 init() 调用 sql.Register() 注册 │
└─────────────────────────────────────────────┘
核心思想:接口与实现分离。 标准库只定义"怎么用",不关心"怎么连"。因此:
-
标准库不包含任何具体数据库驱动;
-
驱动通过实现
driver包的接口接入; -
用户代码只面向
database/sql编程,切换数据库(理论上)只换驱动名和 DSN。
1.2 database/sql 负责什么、不负责什么
| 负责 ✅ | 不负责 ❌ |
|---|---|
| 连接池(建立、缓存、归还、淘汰) | 实际的网络协议与数据库通信 |
| 并发安全(DB/Stmt 可被多 goroutine 共用) | SQL 方言(占位符格式、分页语法) |
坏连接自动重试(maxBadConnRetries = 2) |
SQL 构建/ORM 能力 |
| 参数默认类型校验与转换 | 迁移、建表等管理功能 |
| 结果集到 Go 类型的通用转换 | 连接串解析(DSN 格式由各驱动自定义) |
| Context 取消/超时传播 | 具体数据库特性(需用 Conn.Raw 逃生舱) |
1.3 一句话心智模型
*sql.DB不是一条连接,而是连接池句柄 ;程序全局只需Open一次;每一条查询临时借出连接、用完自动归还;你几乎永远不需要手动管理连接的生命周期,但必须 管好Rows、Stmt、Tx、Conn的 Close。
2. 包结构与源码组成
Go
$GOROOT/src/database/sql/
├── sql.go # 主体:DB/Conn/Tx/Stmt/Rows/Row/Result/DBStats/连接池/事务
├── convert.go # convertAssign 家族:数据库值 → Go 类型的转换引擎
├── ctxutil.go # ctxDriverPrepare/Exec/Query 等,把 ctx 适配给旧式驱动接口
├── doc.txt # 包文档
└── driver/ # 驱动接口定义(驱动作者面向这个包编程)
└── driver.go # Driver/Conn/Stmt/Rows/Tx/Value/NamedValue/...
| 文件 | 关键内容 |
|---|---|
sql.go |
Register、DB 结构与连接池、retry、conn()/putConn()、Tx、Stmt、Rows、NullXxx、connRequestSet |
convert.go |
convertAssign/convertAssignRows/convertAssignString、driverArgsConnLocked、DefaultParameterConverter |
ctxutil.go |
对不感知 context 的驱动,用超时后直接放弃的方式包装(context 取消不保证中断底层查询) |
driver/driver.go |
全部驱动接口 + Value/NamedValue 类型 + ErrSkip/ErrBadConn 哨兵错误 |
3. 驱动体系与 driver 子包
3.1 注册机制
驱动在 init() 中调用 sql.Register,以名字注册到全局 drivers 表(源码中是包级 var drivers = make(map[string]driver.Driver),由 driversMu sync.RWMutex 保护):
Go
// 驱动包内部(以 go-sql-driver/mysql 为例)
func init() {
sql.Register("mysql", &MySQLDriver{})
}
用户侧通过匿名导入触发注册:
Register 的两个 panic 条件:同名重复注册、driver 为 nil。sql.Drivers() 可列出所有已注册驱动名(按字典序)。
3.2 driver 包核心接口速览
以下接口按"驱动需要实现 / 可选实现"分类(☆ 为可选增强接口):
Go
// 入口:名字 → 驱动
type Driver interface {
Open(name string) (Conn, error)
}
☆ type DriverContext interface { // 支持解析 DSN 后再建连接器
OpenConnector(name string) (Connector, error)
}
type Connector interface { // Go 1.10+ 推荐入口
Connect(context.Context) (Conn, error)
Driver() Driver
}
// 连接
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}
☆ type Pinger interface { Ping(ctx) error } // 支持 db.Ping 真探活
☆ type Execer interface { Exec(query, args) (Result, error) } // 跳过 Prepare 直接执行
☆ type ExecerContext interface { ExecContext(ctx, query, args []NamedValue) (Result, error) }
☆ type Queryer interface { Query(query, args) (Rows, error) }
☆ type QueryerContext interface { QueryContext(ctx, query, args []NamedValue) (Rows, error) }
☆ type SessionResetter interface { ResetSession(ctx) error } // 归还池前重置会话状态
☆ type Validator interface { IsValid() bool } // 归还池前校验连接活性
// 预处理语句
type Stmt interface {
Close() error
NumInput() int // 占位符个数;-1 表示未知
Exec(args []NamedValue) (Result, error)
Query(args []NamedValue) (Rows, error)
}
☆ type StmtExecContext interface { StmtExecContext(ctx, args) (Result, error) }
☆ type StmtQueryContext interface { StmtQueryContext(ctx, args) (Rows, error) }
// 结果集
type Rows interface {
Columns() []string
Close() error
Next(dest []Value) error // io.EOF 表示结束
}
☆ type RowsNextResultSet interface { HasNextResultSet() bool; NextResultSet() error }
☆ type RowsColumnTypeDatabaseTypeName / Length / Nullable / PrecisionScale / ScanType
// 事务
type Tx interface {
Commit() error
Rollback() error
}
// 参数与返回值的类型约束
type Value = any // 仅允许 nil,int64,float64,bool,[]byte,string,time.Time
type NamedValue struct {
Name string
Ordinal int
Value Value
}
// 错误哨兵
var ErrSkip = errors.New("driver: skip fast-path; continue as if unimplemented")
var ErrBadConn = errors.New("driver: bad connection")
driver.Value 只允许 6 种类型 + nil :int64、float64、bool、[]byte、string、time.Time。这是整个包类型系统的"通用货币",入参出参都要归一到它。驱动返回其它类型时,convert.go 里还有一套兼容转换(如 int32 → int64、*string → string)。
3.3 ExecerContext / QueryerContext:快路径的关键
database/sql 执行带参数的 SQL 时的查找顺序(以 db.QueryContext 为例,见 queryDC):
-
若驱动实现了
driver.QueryerContext(或旧Queryer),优先直接把 SQL 文本 + 参数交给驱动(驱动可在内部插值或走协议级预编译,如 MySQL 驱动的客户端插值、pgx 的扩展协议绑定); -
驱动返回
driver.ErrSkip时,退回慢路径:Prepare → Stmt.Query → Stmt.Close(一次一编译一销毁); -
驱动没实现这些接口,直接走慢路径。
ExecContext 对 driver.ExecerContext 的处理完全同理。这就是"直接 db.Query 到底有没有额外 Prepare 开销"的答案:取决于驱动 ,MySQL 驱动默认用文本协议插值(需 interpolateParams=true 才在客户端插值),pgx stdlib 默认走扩展协议绑定,均无显式 Prepare 往返。
3.4 ErrBadConn 与连接健康
-
驱动在任何接口方法中返回
driver.ErrBadConn,表示"这条连接坏了,请换一条"。 -
database/sql收到后:丢弃该连接、尝试打开新连接、自动重试(详见 6.6 节)。 -
Pinger/Validator/SessionResetter三个可选接口共同决定了坏连接能被多早发现(借出前校验 vs 用时才报错)。
3.5 常用驱动
| 数据库 | 驱动 | 注册名 | 备注 |
|---|---|---|---|
| MySQL/MariaDB | github.com/go-sql-driver/mysql |
mysql |
事实标准,纯 Go |
| PostgreSQL | github.com/jackc/pgx/v5/stdlib |
pgx |
推荐;纯 Go |
| PostgreSQL | github.com/lib/pq |
postgres |
仅维护模式 |
| SQLite | modernc.org/sqlite |
sqlite |
纯 Go,无 cgo |
| SQLite | github.com/mattn/go-sqlite3 |
sqlite3 |
需 cgo,功能全 |
| SQL Server | github.com/microsoft/go-mssqldb |
sqlserver/mssql |
微软官方 fork |
| Oracle | github.com/godror/godror |
godror |
需 cgo + 客户端库 |
| ClickHouse | github.com/ClickHouse/clickhouse-go/v2 |
clickhouse |
4. 核心数据类型总览
Go
DB(连接池句柄,并发安全,全局一个)
├── Conn(从池里借出的单连接会话,需 Close 归还)
├── Tx(事务:独占一条连接直至 Commit/Rollback)
├── Stmt(预处理语句:DB 级可跨连接,自动按需重编译)
│ └── css []connStmt ← 每条连接上对应的 driverStmt 列表
└── 查询产物
├── *Rows(多行游标,必须 Close 或迭代到底)
├── *Row(单行封装,错误延迟到 Scan)
└── Result(Exec 的返回:LastInsertId / RowsAffected)
辅助类型:
· DBStats 池运行指标
· ColumnType 列元信息(名称/可空/长度/精度/扫描类型)
· NamedArg/Out 命名参数 / 存储过程出参
· NullString/NullInt64/.../Null[T] 可空值包装
· RawBytes 零拷贝字节切片(生命周期受严格限制)
· Scanner / driver.Valuer 自定义双向转换接口
生命周期责任表:
| 类型 | 谁创建 | 谁负责结束 | 忘了的后果 |
|---|---|---|---|
*DB |
Open/OpenDB |
程序退出前 Close(可不做) |
连接与后台 goroutine 泄漏 |
*Rows |
Query |
迭代完自动关,否则手动 Close | 连接被长期占用,池耗尽 |
*Row |
QueryRow |
Scan 内部自动 Close | 无 |
*Stmt |
Prepare |
手动 Close(Tx 内的随事务关闭) | 每条连接上泄漏一个服务端语句 |
*Tx |
Begin |
必须 Commit 或 Rollback | 连接被独占直至 ctx 取消 |
*Conn |
db.Conn |
手动 Close | 连接永远回不到池里 |
5. 打开数据库:Open / OpenDB / Ping / Close
5.1 Open ------ 惰性建池
Go
func Open(driverName, dataSourceName string) (*DB, error)
源码逻辑(sql.go):
Go
func Open(driverName, dataSourceName string) (*DB, error) {
driversMu.RLock()
driveri, ok := drivers[driverName]
driversMu.RUnlock()
if !ok {
return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
}
// 驱动实现了 DriverContext 则先解析 DSN 生成 Connector(可提前暴露 DSN 错误)
if driverCtx, ok := driveri.(driver.DriverContext); ok {
connector, err := driverCtx.OpenConnector(dataSourceName)
if err != nil { return nil, err }
return OpenDB(connector), nil
}
return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}
三个要点:
-
Open不建立任何真实连接 ------它只查驱动、构造*DB、启动connectionOpener后台 goroutine。DSN 错误往往要到第一条 SQL 才暴露,所以惯用法是Open后立刻db.Ping()。 -
未注册驱动时报错文案
forgotten import?是最常见的新手问题(忘写匿名导入)。 -
*DB全局只创建一次 (如main里或依赖注入容器里),所有 goroutine 共享;反复 Open 同一 DSN 会创建多个互相独立的池。
5.2 OpenDB ------ Connector 方式
Go
func OpenDB(c driver.Connector) *DB
绕过字符串 DSN,直接注入 driver.Connector。用途:驱动提供的专属连接器(如 mysql.NewConnector,可分别传 DSN 配置对象)、或自研中间层(连接加密、影子库路由等)。OpenDB 内部:
Go
func OpenDB(c driver.Connector) *DB {
ctx, cancel := context.WithCancel(context.Background())
db := &DB{
connector: c,
openerCh: make(chan struct{}, connectionRequestQueueSize), // 容量 1000000
lastPut: make(map[*driverConn]string),
stop: cancel,
}
go db.connectionOpener(ctx) // 后台开连接 goroutine,伴随 DB 一生
return db
}
5.3 Ping ------ 探活
Go
func (db *DB) Ping() error
func (db *DB) PingContext(ctx context.Context) error
从池中借一条连接(必要时新建);若驱动实现 driver.Pinger,调用真实 Ping;未实现则"借到连接"本身即视为成功。PingContext 走 db.retry(...),坏连接自动换新。
5.4 Close ------ 关池
幂等(重复调用返回 nil)。流程:置 closed 标记 → 关闭所有空闲连接 → connRequests.CloseAndRemoveAll()(所有等连接的调用方立即收到 errDBClosed)→ stop() 终止 opener goroutine → 若 Connector 实现了 io.Closer 一并关闭。注意:只关空闲连接,正在使用的连接等归还时再关;进行中的查询不会被强杀。
6. 连接池实现剖析(源码级)
这是 database/sql 最精华、面试与调优最常考的部分。
6.1 总体架构
Go
goroutine A goroutine B
│ QueryContext │ ExecContext
▼ ▼
db.conn(ctx, cachedOrNewConn) │
│ │
┌─────────▼─────────────────────────▼─────────┐
│ db.mu 保护的池状态 │
│ freeConn []*driverConn 空闲连接(按归还 │
│ 时间排序,旧→新) │
│ connRequests connRequestSet 等待者集合 │
│ numOpen int 已打开+打开中数量 │
│ maxOpen / maxIdleCount / maxLifetime / │
│ maxIdleTime 四大配置 │
└───────┬──────────────────────────┬──────────┘
│ 有空闲 → 直接取 │ 无空闲且达 maxOpen
│ │ → 加入 connRequests 挂起等待
│ ▼
│ openerCh <- struct{}{}
│ │
│ ▼
│ connectionOpener goroutine(随 DB 启动)
│ │ openNewConnection
│ ▼
│ connector.Connect(ctx) 真实拨号
│ │
│ 成功 → 直接投递给等待者 / 放入 freeConn
▼ ▼
执行完毕 putConn(dc, err, resetSession)
│
├─ 有等待者(TakeRandom 随机挑一个)→ 直接移交,不进 freeConn
└─ 无等待者 → 放回 freeConn(超过 maxIdle 则关闭)
另有 connectionCleaner goroutine:设置了 maxLifetime/maxIdleTime 且有连接后启动,周期性(最短 1s)清理过期空闲连接。
6.2 DB 结构体逐字段解读(go1.24.0 源码节选)
Go
type DB struct {
waitDuration atomic.Int64 // 累计等待连接的纳秒数(供 DBStats)
connector driver.Connector // 连接工厂(Open/OpenDB 传入)
numClosed atomic.Uint64 // 历史关闭连接总数;Stmt 借它判断
// css 里哪些 driverStmt 已失效
mu sync.Mutex // 保护以下字段
freeConn []*driverConn // 空闲连接,按 returnedAt 旧→新排序
connRequests connRequestSet // 等待连接的 chan 集合(slice 实现)
numOpen int // 已打开 + 正在打开 的连接数
openerCh chan struct{} // 唤醒 connectionOpener 的信号
closed bool
dep map[finalCloser]depSet // 对象依赖图(Stmt↔conn 等,驱动关闭顺序)
lastPut map[*driverConn]string // 调试用:上次归还的调用栈
maxIdleCount int // 0→默认2;负数→0
maxOpen int // <=0 → 无限
maxLifetime time.Duration // 连接最长存活期(从创建算)
maxIdleTime time.Duration // 空闲最长存活期(从归还算)
cleanerCh chan struct{} // 唤醒 cleaner
waitCount int64 // 等待过的总次数
maxIdleClosed int64 // 因超过 maxIdle 被关的数量
maxIdleTimeClosed int64 // 因空闲超时被关的数量
maxLifetimeClosed int64 // 因寿命到期被关的数量
stop func() // 取消 opener 的 context
}
要点:
-
freeConn是切片不是 map ,按"归还时间从旧到新"排序;取空闲时取尾部(最旧的) ------源码注释:Reuse the lowest idle time connection so we can close connections which remain idle as soon as possible,即优先用"再不用就要被空闲超时淘汰"的那条,配合 cleaner 高效裁剪。 -
connRequests在 Go 1.24 中是connRequestSet------基于 slice 的集合,支持 Add/Delete/TakeRandom 。源码注释明确说明:以前用map[uint64]chan connRequest,随机取元素要遍历 mapiters 太贵,遂改为 slice + 指针索引实现 O(1) 随机弹出。归还连接时是"随机"挑一个等待者满足,而非 FIFO------避免队头等待者已被 ctx 取消时白白浪费一条连接。 -
numOpen是"乐观计数":发起拨号前就++,失败再--,保证并发下不超maxOpen。
6.3 获取连接:db.conn(ctx, strategy) 完整流程
Go
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error)
strategy 有两种:cachedOrNewConn(优先复用空闲)与 alwaysNewConn(强制新建,重试耗尽后使用)。
Go
1. db.closed? → 返回 errDBClosed
2. ctx 已超时/取消? → 返回 ctx.Err()
3. strategy==cachedOrNewConn 且 freeConn 非空?
├─ 取尾部(最旧)空闲连接,标记 inUse=true
├─ 连接寿命超 maxLifetime? → 关闭它,返回 driver.ErrBadConn(触发上层重试)
├─ resetSession(若驱动实现 SessionResetter 且 needReset)失败?
│ → 关闭,返回 ErrBadConn
└─ 成功返回
4. maxOpen>0 且 numOpen>=maxOpen? (没空闲且不能新建)
├─ 构造带缓冲 chan connRequest,加入 connRequests(waitCount++)
└─ select { <-ctx.Done() → 从集合摘除,返回 ctx.Err()
<-req → 校验寿命/会话后返回 }
(若摘除失败说明恰有并发归还者正要投递 → 尽力读走并归还,防泄漏)
5. 否则:numOpen++(乐观) → connector.Connect(ctx) 真实拨号
├─ 失败:numOpen--,maybeOpenNewConnections(),返回 err
└─ 成功:包装为 driverConn{inUse:true} 返回
6.4 归还连接:db.putConn(dc, err, resetSession)
Go
1. err 不是 ErrBadConn 时先 validateConnection:
驱动实现 Validator 则调 IsValid(),失败 → 视为 ErrBadConn
2. dc 不在 inUse 状态?→ panic("sql: connection returned that was never out")
(内部不变量被破坏,典型于 Rows/Conn 被重复归还)
3. 寿命超 maxLifetime → 标记为坏连接
4. inUse=false;returnedAt=now;执行 dc.onPut 里挂起的清理(如失效 stmt 关闭)
5. 若最终判定为 ErrBadConn:
maybeOpenNewConnections() 补充新连接 → dc.Close() 销毁
6. 否则 putConnDBLocked:
├─ 池已 closed / numOpen>maxOpen → 关闭
├─ connRequests.TakeRandom() 命中等待者 → 直接把连接塞进它的 chan
│ (跳过 freeConn,免进免出,零开销移交)
└─ 无等待者 → freeConn 未超 maxIdle 则入列;
超了 → maxIdleClosed++,关闭
6.3 与 6.4 合起来回答一个经典问题:为什么"并发短查询"时连接不抖动? 因为归还者在发现有等待者时直接移交连接,等待者拿到的连接从未真正进入过 freeConn,锁开销极小。
6.5 后台 goroutine:opener 与 cleaner
connectionOpener (每个 DB 一个,OpenDB 时启动):
Go
func (db *DB) connectionOpener(ctx context.Context) {
for {
select {
case <-ctx.Done(): return
case <-db.openerCh: db.openNewConnection(ctx)
}
}
}
maybeOpenNewConnections()(在等待者入队、连接被判坏等时机调用)按"等待者数量与 maxOpen 的缺口"向 openerCh 发 N 个信号,每个信号对应一次 numOpen++(乐观)+ 一次拨号 。拨号失败的错误会通过 putConnDBLocked(nil, err) 投递给随机等待者------即连接建立失败也会让某个等待者立刻收到错误。
connectionCleaner(设置了 lifetime/idleTime 且有连接后才启动):
-
周期 = min(maxIdleTime, maxLifetime) 的下一个到期点,最短 1 秒;
-
只清理
freeConn里的空闲连接(按 returnedAt/createdAt 判断),使用中的不动; -
缩短配置时会通过
cleanerCh立即唤醒;d<=0或池空时自动退出(下次设置再启动); -
过期连接还可能被"惰性"清理 :借出时发现
expired(maxLifetime)也会关闭并当作坏连接处理(见 6.3 第 3 步),所以即便 cleaner 周期较长也不会用到超龄连接。
6.6 坏连接重试:retry 与 maxBadConnRetries
Go
const maxBadConnRetries = 2
func (db *DB) retry(fn func(strategy connReuseStrategy) error) error {
for i := int64(0); i < maxBadConnRetries; i++ {
err := fn(cachedOrNewConn)
if err == nil || !errors.Is(err, driver.ErrBadConn) {
return err
}
}
return fn(alwaysNewConn) // 最后一次强制新连接
}
所有顶层入口(PingContext、ExecContext、QueryContext、PrepareContext、BeginTx、Conn)都套了这层 retry:同一操作最多 2 次复用池内连接,若都报 ErrBadConn,则第 3 次强制新建连接。含义:
-
数据库重启、网络闪断后,池里的陈旧连接最多再"坑"你两次,第三次必然触发真实拨号;
-
但只有
ErrBadConn触发重试。SQL 语法错、约束冲突等业务错误不会(也不应)重试; -
事务内部(
Tx.ExecContext等)不走 retry------事务绑定单连接,坏连接直接报错,由事务语义决定回滚。
6.7 连接池四大配置参数
| 方法 | 默认值 | 语义 | 调优建议 |
|---|---|---|---|
SetMaxOpenConns(n) |
0(无限) | 同时存在的连接上限(含使用中+空闲+拨号中) | 必须设置 。参考:CPU 密集型小服务 5~10 足够;典型 Web 服务 25~100;上限受制于数据库侧 max_connections ÷ 实例数。超出后新请求阻塞等待(可被 ctx 打断) |
SetMaxIdleConns(n) |
2 | 空闲池上限;若 n > maxOpen 会被钳制到 maxOpen | 短查询高频服务建议 ≈ MaxOpen ,否则流量峰谷间连接被频繁关闭重建(maxIdleClosed 飙升)。n<=0 表示不保留空闲连接 |
SetConnMaxLifetime(d) |
0(永不过期) | 连接自创建起最长存活 | 建议小于数据库/中间件的空闲超时与 LB 的连接最长寿命,常用 5min~1h。轮换连接可防止长连接导致的内存累积、DNS 变更不生效等问题 |
SetConnMaxIdleTime(d) |
0(永不清理) | 连接自归还起空闲多久后关闭 | 峰谷明显的服务可设(如 10min),谷底释放连接;与 Lifetime 二者较短者先生效 |
经验配置模板:
Go
db.SetMaxOpenConns(64)
db.SetMaxIdleConns(64) // 与 MaxOpen 相等,避免峰谷抖动
db.SetConnMaxLifetime(30 * time.Minute) // 主动轮换
db.SetConnMaxIdleTime(10 * time.Minute) // 谷底回收
⚠️ 两个经典坑:
默认
MaxIdleConns=2+ 高并发 →WaitCount/WaitDuration暴涨、连接不停重建。这是不配置时最常见的性能问题。
MaxIdleConns<MaxOpenConns且相差悬殊 → 大量maxIdleClosed,连接池退化为"频繁拨号机"。
6.8 DBStats:池的可观测性
Go
type DBStats struct {
MaxOpenConnections int // 配置的 maxOpen
OpenConnections int // numOpen(使用中+空闲)
InUse int // 使用中
Idle int // 空闲
WaitCount int64 // 累计"没空闲连接且达上限"而等待的次数
WaitDuration time.Duration // 累计等待耗时
MaxIdleClosed int64 // 因超过 maxIdle 关闭的
MaxIdleTimeClosed int64 // 因空闲超时关闭的
MaxLifetimeClosed int64 // 因寿命到期关闭的
}
stats := db.Stats() // 快照,可周期性上报 Prometheus
判读口诀:WaitCount 高 → MaxOpen 不够或数据库慢;MaxIdleClosed 高 → MaxIdle 设小了;MaxLifetimeClosed 高 → Lifetime 轮换在生效(正常)。
7. 查询:Query / QueryRow / Rows
7.1 Query 家族
Go
func (db *DB) Query(query string, args ...any) (*Rows, error)
func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error)
// Tx、Conn、Stmt 上均有对应方法,语义一致
执行流程(db.query → queryDC):
Go
retry 包裹:
借连接(db.conn)
├─ 快路径:驱动实现 QueryerContext → 直接发 SQL+参数
│ 成功 → Rows{dc, releaseConn, rowsi}
│ ErrSkip → 落入慢路径
└─ 慢路径:Prepare → driverArgs 转换 → Stmt.QueryContext
→ Rows{dc, releaseConn, rowsi, closeStmt}
注意:**连接所有权移交给 *Rows**,Rows.Close 时才 releaseConn 归还
7.2 Rows 的正确迭代协议
Go
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users WHERE age > ?", 18)
if err != nil {
return err
}
defer rows.Close() // ① 必写:防止中途 return 泄漏连接
for rows.Next() { // ② 驱动游标推进,填充 lastcols
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil { // ③ 列数必须匹配
return err
}
fmt.Println(id, name)
}
if err := rows.Err(); err != nil { // ④ 必查:区分"正常结束"与"迭代中出错"
return err
}
四步纪律的依据(源码行为):
-
Next()返回 false 有两种原因:数据读完(rowsi.Next返回io.EOF)或驱动报错 ;Rows.Err()返回后者(以及 context 取消错误)。漏查会把网络错误、ctx 超时静默吞掉。 -
Next()遍历到末尾且无更多结果集时,Rows 自动 Close (源码:nextLocked返回doClose=true→rs.Close()),此时再手动Close也无害(幂等)。但中途break/return必须依赖defer rows.Close()。 -
Scan前必须先Next()成功;列数不匹配报expected %d destination arguments in Scan, not %d。 -
Scan 出错会带列定位信息:
sql: Scan error on column index %d, name %q: %w。
7.3 Rows 全部方法
| 方法 | 说明 |
|---|---|
Next() bool |
推进游标;false 时查 Err() |
Scan(dest ...any) error |
当前行拷贝到 dest(转换规则见第 10 节) |
Err() error |
迭代期错误(含 context 错误) |
Close() error |
关闭游标并归还连接;幂等 |
Columns() ([]string, error) |
列名 |
ColumnTypes() ([]*ColumnType, error) |
列元信息 |
NextResultSet() bool |
多结果集(存储过程、多语句);配合再次 Next() |
7.4 QueryRow 与 *Row:延迟错误设计
Go
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", id)
var name string
if err := row.Scan(&name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没查到 ≠ 程序错误,按业务处理
}
return err
}
QueryRow永远返回非 nil 的 *Row ,把错误藏进去,Scan 时才吐出------这样才能支持链式一行写法。Row.Scan 内部:无行 → ErrNoRows;多行 → 取第一行丢弃其余;并禁止 *RawBytes 参数(因为 Scan 返回时会自动 Close,零拷贝内存立即失效)。Row.Err() 可在不 Scan 的情况下取执行错误。
7.5 RawBytes:零拷贝的正确姿势
Go
var buf sql.RawBytes
rows, _ := db.Query("SELECT blob_col FROM t")
for rows.Next() {
rows.Scan(&buf) // buf 直接指向驱动内部缓冲
use(buf) // 只在本次循环内有效!
// 下一次 Next/Scan/Close 后 buf 指向的内存作废
}
省一次大字段拷贝,但生命周期严格限制在下一次 Next/Scan/Close 之前 ,需要留存必须 append([]byte(nil), buf...)。源码为此实现了 closemuScanHold:Scan 到 RawBytes 时持有读锁,防止后台 awaitDone 抢先 Close(修复 go.dev/issue/60304)。
8. 执行写操作:Exec / Result
Go
func (db *DB) ExecContext(ctx context.Context, query string, args ...any) (Result, error)
type Result interface {
LastInsertId() (int64, error) // 自增主键回填值
RowsAffected() (int64, error) // 影响行数
}
-
用于 INSERT / UPDATE / DELETE / DDL 等不返回行的语句;返回
*Rows(其rowsi为 nil)则是误用。 -
快路径走
driver.ExecerContext(同 3.3);慢路径临时 Prepare→执行→立刻关闭语句。 -
LastInsertId并非所有数据库/驱动支持(如 PostgreSQL 协议层面无此概念,lib/pq 返回错误;pgx 同样)。PG 取自增键的正确方式是INSERT ... RETURNING id+ QueryRow。MySQL 两者都支持。 -
批量插入:多条 VALUES 一条 INSERT(
INSERT INTO t VALUES (?),(?),(?))远快于循环单条 Exec(减少网络往返与事务提交次数)。
9. 参数处理与占位符
9.1 占位符由驱动方言决定
| 数据库 | 占位符风格 |
|---|---|
| MySQL | ? |
| PostgreSQL | $1, $2, ... |
| SQLite | ? 或 $1 |
| SQL Server | @p1 或 ? |
database/sql 本身不解析 SQL,占位符原样透传给驱动,写错报错来自驱动/数据库。
9.2 参数类型归一
用户参数先经 driverArgsConnLocked → 驱动的 driver.Connector/Conn 上可选的 driver.NamedValueChecker;没有则用 driver.DefaultParameterConverter.ConvertValue 归一:
-
允许直接通过:6 种
driver.Value基础类型; -
各类有符号/无符号整型 →
int64(注意:uint64 超过 math.MaxInt64 会报错); -
各类浮点 →
float64; -
实现
driver.Valuer的类型 → 先调Value()再递归归一(struct、自定义类型挂载点); -
布尔、字符串、
[]byte直通;nil直通(NULL); -
其余类型(如切片、map)默认报错 ,需要自定义
Valuer或驱动支持(pgx 驱动自己扩展支持了数组、JSON 等,这是"通用接口 + 驱动增强"的典型体现)。
9.3 命名参数与出参
Go
// 命名参数(sql.Named / NamedArg)
db.ExecContext(ctx, `
DELETE FROM invoice
WHERE TimeCreated < @end AND TimeCreated >= @start`,
sql.Named("start", startTime),
sql.Named("end", endTime),
)
// 前提:驱动实现了 NamedValueChecker 且支持命名参数(pgx、sqlserver 等;
// MySQL go-sql-driver 不支持命名参数)
// 存储过程出参
var out string
db.ExecContext(ctx, "ProcName", sql.Named("Arg1", sql.Out{Dest: &out}))
9.4 SQL 注入防线
永远用占位符,不要字符串拼接:
Go
// ❌ 注入漏洞
q := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", input)
// ✅ 参数化
rows, err := db.Query("SELECT * FROM users WHERE name = ?", input)
参数化后,值由驱动按协议编码传输(MySQL 文本协议下由驱动负责转义/pgx 走绑定),与 SQL 结构彻底分离。注意:表名、列名、ORDER BY 字段不能用占位符,这些必须走白名单校验。
10. Scan 类型转换规则(convert.go)
10.1 数据流
Go
驱动返回 []driver.Value(每行一槽,仅 6 基础类型 + nil)
│
▼ convertAssignRows(dest[i], sv, rs) ← Rows.Scan 逐列调用
┌────┴─────────────────────────────────────┐
│ dest 是 *any → 原样放入([]byte 会拷贝)
│ dest 实现 Scanner → 调用 dest.Scan(sv),错误被包装
│ sv 实现 Valuer → 先取 Value 再继续转换
│ 其余 → 走类型矩阵(下表)
└──────────────────────────────────────────┘
10.2 源 → 目标 转换矩阵(核心路径)
| 源(driver.Value) | 可扫描的目标类型(取指针传入 Scan) |
|---|---|
int64 |
*int/.../int64、*uint/.../uint64(溢出报错)、*float32/64、*bool(仅 0/1)、*string、*[]byte、*any、*RawBytes |
float64 |
*float32/64、*int*/*uint*(须无小数且不溢出)、*string、*any... |
bool |
*bool、*any;反向:目标 *bool 时源可为 true/false、int64 的 1/0、可被 strconv.ParseBool 的字符串 |
[]byte |
*[]byte(拷贝)、*string、*any(拷贝)、*RawBytes(零拷贝)、*time.Time(尝试解析) |
string |
*string、*[]byte、数值目标(字符串转数字,溢出/格式错报错)、*bool |
time.Time |
*time.Time、*any、*string、*[]byte(后两者按 RFC3339Nano 格式化) |
nil |
任何目标走"零值路径":基本类型置零;*any 置 nil;非指针包装类型直接报错(见第 14 节) |
补充规则(源码级细节,convertAssign 内):
-
数值串/数字互转检查信息丢失 :如
"300"能进uint16不能进uint8;float64(255)可进uint8,256不行。 -
uint64大于MaxInt64的源,进有符号目标会报错。 -
驱动返回的源如果不是 6 基础类型(convert.go 里有防御性兼容,比如老驱动返回
int32、*string、time.Time的变体),会先做一层driver.DefaultParameterConverter式的归一。 -
目标是
*any时:源为[]byte会拷贝一份交给调用方永久持有------这是"驱动缓冲区可能被复用"契约决定的。
10.3 自定义双向转换:Scanner 与 Valuer
Go
// 入库方向:Go 类型 → driver.Value
type Valuer interface {
Value() (driver.Value, error)
}
// 出库方向:driver.Value → Go 类型
type Scanner interface {
Scan(src any) error // src 只可能是 6 基础类型或 nil
}
自定义类型示例(JSON 字段落库):
Go
type Extra struct {
Tel string `json:"tel"`
Addr string `json:"addr"`
}
type ExtraJSON Extra // 别名避免递归实现
func (e Extra) Value() (driver.Value, error) { // 入库
return json.Marshal(ExtraJSON(e))
}
func (e *Extra) Scan(src any) error { // 出库
switch v := src.(type) {
case nil:
return nil
case []byte:
return json.Unmarshal(v, (*ExtraJSON)(e))
case string:
return json.Unmarshal([]byte(v), (*ExtraJSON)(e))
default:
return fmt.Errorf("Extra.Scan: unsupported type %T", src)
}
}
// 之后 Extra 可直接作为参数与 Scan 目标使用
陷阱:
Value()/Scan()的接收者如果与自身类型相同会形成无限递归(值接收者 ↔ 指针接收者混用时尤其常见),惯用法是像上例一样用"别名类型"隔一层。
11. 预处理语句 Stmt
11.1 两种创建方式
Go
// A. DB 级:池化语句,跨连接复用
stmt, err := db.PrepareContext(ctx, "SELECT name FROM users WHERE id = ?")
defer stmt.Close()
// B. Tx/Conn 级:绑定到当前事务/连接,随 Tx/Conn 结束自动关闭
stmt, err := tx.Prepare("UPDATE balance SET money = money + ? WHERE id = ?")
11.2 Stmt 的多连接管理:css 列表(源码级)
先纠正一个广泛流传的错误认知:database/sql 并没有"每连接 LRU 语句缓存"。 go1.24.0 源码中 driverConn.prepareLocked 是无条件真实 Prepare:
Go
func (dc *driverConn) prepareLocked(ctx context.Context, cg stmtConnGrabber,
query string) (*driverStmt, error) {
si, err := ctxDriverPrepare(ctx, dc.ci, query) // 每次都真正下发 PREPARE
...
dc.openStmt[ds] = true // 登记以便连接关闭前清理
return ds, nil
}
复用发生在 *sql.Stmt 对象内部 :Stmt.css []connStmt 记录"每条 driverConn 上对应的 driverStmt":
Go
type Stmt struct {
db *DB
query string
cg stmtConnGrabber // 非 nil 表示绑定在 Tx/Conn 上(只用其连接)
cgds *driverStmt // 绑定模式下唯一的 driverStmt
css []connStmt // 池化模式:连接 → 该连接上的语句实例
...
}
执行时(connStmt):先借一条连接;在 css 里线性查找 该连接对应的语句------找到直接用,找不到就在这条连接上重新 Prepare 并追加进 css 。另有节流清理:removeClosedStmtLocked 只在"已关闭连接数 - 上次记录 ≥ max(len(css)/2+1, 10 上限)"时才扫描剔除失效项,避免频繁抢 db.mu。
推论与调优含义:
-
db.Prepare后的第一个请求在每条新接触的连接上都要付一次 Prepare 往返;池越大,"冷启动"语句越多; -
由于主流驱动(MySQL 文本协议除外、pgx、mssql)在
QueryerContext/ExecerContext快路径里本来就有协议级绑定,db.Query直发往往不比stmt.Query慢; -
Prepare真正的收益场景:①同一语句在同一条连接上高频执行(Tx/Conn 内);②驱动无快路径;③语句参数极多、SQL 很长,想省传输量;④依赖服务端执行计划缓存。 -
stmt是并发安全的,可在多个 goroutine 间共享;Close()幂等。
11.3 Tx.Stmt:把 DB 级语句带进事务
Go
updateMoney, _ := db.Prepare("UPDATE balance SET money = money + ? WHERE id = ?")
tx, _ := db.Begin()
res, err := tx.Stmt(updateMoney).Exec(123.45, 98293203) // 自动在事务连接上重编译
tx.Stmt 会优先复用 updateMoney.css 中"恰好是事务这条连接"上的语句;否则重编译。事务内的 tx.Prepare 生成的语句由事务 Commit/Rollback 统一关闭。
12. 事务 Tx
12.1 基本用法与黄金模板
Go
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // ① 安全网:已 Commit 后再 Rollback 返回 ErrTxDone,无害
// ② 事务内全部用 tx 的方法,绝不再碰 db
if _, err := tx.ExecContext(ctx, "UPDATE a SET x = x - 10 WHERE id = ?", 1); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, "UPDATE b SET y = y + 10 WHERE id = ?", 1); err != nil {
return err
}
return tx.Commit() // ③ 成功才提交
为什么 defer tx.Rollback() 是安全网:源码中 rollback 用 done.CompareAndSwap(false, true) 保证恰好生效一次 ,Commit 之后的 Rollback 只会拿到 ErrTxDone,不会真正回滚已提交事务。
12.2 隔离级别
Go
type TxOptions struct {
Isolation IsolationLevel
ReadOnly bool
}
const (
LevelDefault // 驱动/数据库默认
LevelReadUncommitted
LevelReadCommitted
LevelWriteCommitted // Go 1.11+
LevelRepeatableRead
LevelSnapshot
LevelSerializable
LevelLinearizable
)
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
ReadOnly: true,
})
隔离级别通过 ctxDriverBegin → 驱动的 driver.ConnBeginTx 传递;驱动不支持时返回错误(不会静默降级)。MySQL 驱动支持到 Serializable;PostgreSQL(pgx/lib/pq)支持到 Serializable(Snapshot 对应 RR)。
12.3 事务与 Context:自动回滚
BeginTx 内部(源码 beginDC):
Go
ctx, cancel := context.WithCancel(ctx)
tx = &Tx{..., cancel: cancel, ctx: ctx}
go tx.awaitDone() // 后台 goroutine 监视 ctx
awaitDone 阻塞在 <-tx.ctx.Done()------ctx 超时或取消时自动 Rollback 并归还(或销毁)连接;Commit/Rollback 完成后 cancel 被 defer 调用,该 goroutine 退出。因此:
-
传给 BeginTx 的 ctx 是整个事务的预算,不是单条语句的;事务内各语句建议再用更短的 ctx 派生;
-
ctx 取消后调用
tx.Commit()会直接返回 ctx.Err()(Commit 入口先检查 ctx.Done),不会提交一个已回滚的事务。
12.4 事务独占一条连接
BeginTx 借出连接后直到 Commit/Rollback 才归还 (源码:Tx.dc 独占,grabConn 校验事务存活后发放)。推论:
-
长事务 = 长期占用池容量,并发下会推高 WaitCount;事务要短,别把外部 HTTP 调用塞进事务里;
-
事务内混用
db.Exec(走池里另一条 连接)会破坏原子性且看不到未提交数据------事务内一律用 tx 方法。
12.5 连接回收细节:keepConnOnRollback
beginDC 源码:
Go
_, hasSessionResetter := dc.ci.(driver.SessionResetter)
_, hasConnectionValidator := dc.ci.(driver.Validator)
keepConnOnRollback := hasSessionResetter && hasConnectionValidator
-
驱动同时 实现会话重置与活性校验(MySQL、pgx 均如此):Rollback 后连接归还池中复用;
-
否则(小众驱动):Rollback/ctx 取消后直接销毁连接,防止会话状态污染。
12.6 Savepoint 模拟嵌套事务
标准库没有嵌套事务,可用 Savepoint 封装"局部回滚":
Go
func withSavepoint(ctx context.Context, tx *sql.Tx, fn func(*sql.Tx) error) error {
if _, err := tx.ExecContext(ctx, "SAVEPOINT sp"); err != nil {
return err
}
if err := fn(tx); err != nil {
_, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT sp")
return err
}
_, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT sp")
return err
}
13. 单连接会话 Conn
需要临时变量 / SET 会话参数 / 临时表等"同一会话状态"的操作时使用:
Go
conn, err := db.Conn(ctx) // 借一条连接(可能新建)
if err != nil {
return err
}
defer conn.Close() // 归还池中
if _, err := conn.ExecContext(ctx, "SET @my_var = ?", 42); err != nil {
return err
}
rows, err := conn.QueryContext(ctx, "SELECT @my_var + 1") // 同一会话,能看到 @my_var
...
-
Conn上有 Ping/Exec/Query/QueryRow/Prepare/BeginTx 全套方法; -
Close 后所有操作返回
sql.ErrConnDone; -
Conn.Raw(f func(driverConn any) error)是标准库的"逃生舱":把底层driver.Conn原样交给回调,用于调用驱动私有特性(如 MySQL 的Ping特例、pgx 底层类型断言)。f panic 时连接会被标记为坏连接丢弃。业务代码慎用,会锁死驱动选型。
对比:临时变量等会话状态也可用
Tx(同样独占连接),但 Conn 更轻------无需开事务,支持并发查询(同一连接上串行),而 Tx 内所有操作串行且必须以 Commit/Rollback 收尾。
14. NULL 处理
数据库 NULL 扫进非可空 Go 类型会报错(converting NULL to %s is unsupported),四种解法按推荐度排序:
14.1 泛型包装 sql.Null[T](Go 1.22+,首选)
Go
var name sql.Null[string]
var age sql.Null[int32]
err := db.QueryRow("SELECT name, age FROM users WHERE id = ?", id).Scan(&name, &age)
if name.Valid { fmt.Println(name.V) } // Valid=false 表示 NULL
注意 Null[T].Scan → convertAssign(&n.V, value),所以 T 必须是 Scan 支持的基础类型;Null[T].Value() 会经 driver.DefaultParameterConverter 归一(修复过 issue 69728/69837 两个边界)。
14.2 具名包装类型
Go
sql.NullString{String: "x", Valid: true}
sql.NullInt64 / NullInt32 / NullInt16 / NullByte
sql.NullFloat64 / NullBool / NullTime
结构一致:{底层值, Valid bool},同时实现 Scanner 与 Valuer,双向可用。适合团队还没上泛型写法或需要字面量构造参数的场合。
14.3 指针类型
Go
var name *string
rows.Scan(&name) // NULL → name == nil;有值 → 指向副本
零依赖、无包装结构,但双层解引用易出 nil 崩溃;作为参数 传 nil 指针即传 NULL。
14.4 *any
Go
var v any
rows.Scan(&v) // NULL → v == nil;[]byte 源会被拷贝
动态 schema(如 SELECT *)兜底用,配合 rows.ColumnTypes() 反射分发。
15. Context 支持与取消语义
15.1 三类 Context
| Context | 影响范围 |
|---|---|
xxxContext 方法的 ctx |
单次调用:借连接等待、参数转换、语句执行、行迭代(awaitDone 监视) |
BeginTx 的 ctx |
整个事务生命周期;取消 → 自动回滚 |
PrepareContext 的 ctx |
仅编译阶段;后续执行用 Exec/Query 自己的 ctx |
15.2 取消到底能不能"掐断"数据库执行?
ctxutil.go 的老实交代:对没有实现 Context 系列接口 的老驱动,包的保证是"context 取消时 Go 侧立刻放弃等待/关闭 Rows 归还连接",但底层查询可能仍在数据库侧跑完 ------包文档原话:Drivers that do not support context cancellation will not return until after the query is completed. 主流驱动(MySQL、pgx)都实现了 Context 接口并支持 KILL QUERY/取消,该顾虑主要针对小众驱动。
15.3 Rows 与 ctx
initContextClose 为每次带 ctx 的查询启动 awaitDone goroutine:监视查询 ctx、事务 ctx、closectx 三者;任一触发即 rs.close(ctx.Err())------即使你正在迭代中,ctx 超时也会关闭 Rows (之后的 Next 返回 false,Err 返回 context 错误)。RawBytes 场景通过 closemuScanHold 防止"Scan 持有的内存被并发 close 收回"。
15.4 实用模式
Go
// 单条查询 3 秒预算
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT ...")
// 事务 10 秒预算,内部每条语句再各自 2 秒
txCtx, txCancel := context.WithTimeout(ctx, 10*time.Second)
defer txCancel()
tx, err := db.BeginTx(txCtx, nil)
stmtCtx, stmtCancel := context.WithTimeout(txCtx, 2*time.Second)
_, err = tx.ExecContext(stmtCtx, "UPDATE ...")
16. 错误汇总
| 错误 | 触发场景 | 处理建议 |
|---|---|---|
sql.ErrNoRows |
Row.Scan 且无匹配行 |
业务分支,不算失败 |
sql.ErrTxDone |
Tx 已 Commit/Rollback 后再操作;defer Rollback 安全网 | 忽略或断言防御 |
sql.ErrConnDone |
Conn.Close 后再操作 | 代码缺陷,修调用顺序 |
driver.ErrBadConn |
驱动判定连接失效 | 正常情况下被 retry 吞掉;若逃逸到用户,说明驱动异常或重试后仍失败 |
driver.ErrSkip |
驱动快路径不支持本次调用 | 内部哨兵,用户不应见到 |
context.DeadlineExceeded / Canceled |
ctx 超时/取消 | 上抛;注意事务已自动回滚 |
errDBClosed(未导出) |
DB.Close 后操作 | 报 sql: database is closed |
| Scan 错误 | 列数不匹配、类型不可转、NULL 进非空类型、溢出 | 带列索引与列名,定位即可 |
panic: sql: connection returned that was never out |
Rows/Conn 被重复归还(双重 Close 之外的误用) | 检查是否同一 Rows 交给两个人关闭 |
判定一律用 errors.Is/As(包内错误均被 %w 包装透传)。
17. 完整实战示例
以 MySQL 为例;
go get github.com/go-sql-driver/mysql
Go
package main
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
type User struct {
ID int64
Name string
Age sql.Null[int16]
Extra Extra
}
type Extra struct{ City string }
func (e Extra) Value() (driver.Value, error) { return "city=" + e.City, nil }
func (e *Extra) Scan(src any) error {
if src == nil {
return nil
}
b, ok := src.([]byte)
if !ok {
return fmt.Errorf("Extra.Scan: bad type %T", src)
}
_, err := fmt.Sscanf(string(b), "city=%s", &e.City)
return err
}
func main() {
db, err := sql.Open("mysql",
"user:password@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=true")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// ── 连接池配置(生产必做)─────────────────────────
db.SetMaxOpenConns(64)
db.SetMaxIdleConns(64)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(10 * time.Minute)
// ── 探活 ─────────────────────────────────────────
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
log.Fatalf("ping: %v", err)
}
// ── 建表(Exec + DDL)────────────────────────────
_, err = db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
age SMALLINT NULL,
extra VARCHAR(128) NULL)`)
if err != nil {
log.Fatal(err)
}
// ── 插入(参数化 + 批量)─────────────────────────
res, err := db.ExecContext(ctx,
`INSERT INTO users(name, age, extra) VALUES (?,?,?),(?,?,?)`,
"alice", 30, Extra{City: "BJ"},
"bob", nil, Extra{City: "SH"})
if err != nil {
log.Fatal(err)
}
lastID, _ := res.LastInsertId()
affected, _ := res.RowsAffected()
fmt.Println("lastInsertId:", lastID, "affected:", affected)
// ── 查询多行(Rows 协议四件套)───────────────────
rows, err := db.QueryContext(ctx, `SELECT id, name, age, extra FROM users WHERE id > ?`, 0)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Age, &u.Extra); err != nil {
log.Fatal(err)
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
// ── 单行(QueryRow + ErrNoRows)──────────────────
var name string
err = db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, 1).Scan(&name)
switch {
case errors.Is(err, sql.ErrNoRows):
fmt.Println("user 1 not found")
case err != nil:
log.Fatal(err)
default:
fmt.Println("user 1:", name)
}
// ── 事务(黄金模板)──────────────────────────────
txCtx, txCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer txCancel()
if err := transfer(db, txCtx, 1, 2, 5); err != nil {
log.Fatal("transfer:", err)
}
// ── 预处理语句(高频同型语句)────────────────────
stmt, err := db.PrepareContext(ctx, `SELECT name FROM users WHERE id = ?`)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
var n string
if err := stmt.QueryRowContext(ctx, 2).Scan(&n); err != nil {
log.Fatal(err)
}
fmt.Println("stmt query:", n)
// ── 池指标 ───────────────────────────────────────
st := db.Stats()
fmt.Printf("open=%d inUse=%d idle=%d wait=%d waitDur=%v\n",
st.OpenConnections, st.InUse, st.Idle, st.WaitCount, st.WaitDuration)
}
func transfer(db *sql.DB, ctx context.Context, from, to int64, amount int) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // 安全网:Commit 后再调返回 ErrTxDone,无害
if _, err := tx.ExecContext(ctx,
`UPDATE users SET age = age - ? WHERE id = ?`, amount, from); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET age = age + ? WHERE id = ?`, amount, to); err != nil {
return err
}
return tx.Commit()
}
DSN 常用参数(go-sql-driver/mysql):parseTime=true(DATE/DATETIME → time.Time,否则给 \[\]byte)、loc=Local(时区)、charset=utf8mb4、interpolateParams=true(客户端参数插值,减少 Prepare 往返,但存在边缘转义风险)、timeout/readTimeout/writeTimeout。
18. 常见陷阱与最佳实践
18.1 陷阱清单
| # | 陷阱 | 后果 | 正解 |
|---|---|---|---|
| 1 | 循环里 defer rows.Close()(defer 压栈不执行) |
连接耗尽 | defer 只写在查询返回后的函数体顶部;循环体内先 rows.Close() 再进下一轮 |
| 2 | 提前 break 掉 rows 迭代且没写 defer Close |
连接被占,池枯竭,所有请求阻塞 | defer rows.Close() 无脑写 |
| 3 | 不查 rows.Err() |
网络中断/超时被当成"正好没数据" | 循环后必查 |
| 4 | QueryRow 后不判断 err != nil 就 Scan 之外的逻辑 |
误以为有行 | Scan 返回值必检 |
| 5 | NULL 扫进 *string 之外的值类型 |
Scan 报错 | 第 14 节四选一 |
| 6 | 事务里用 db.Exec |
走了另一条连接,原子性/可见性双输 | 事务内只用 tx.* |
| 7 | 忘记 tx.Commit/Rollback(尤其错误分支) |
连接被独占到 ctx 取消或永远 | defer tx.Rollback() 安全网 |
| 8 | 把 Open 写进 HTTP handler |
每请求一个新池,连接数爆炸 | 全局单例 DB |
| 9 | 不配连接池参数 | 默认 maxIdle=2 + 无限 maxOpen,高并发下抖动 | 6.7 节模板 |
| 10 | Stmt/Conn 不 Close |
每连接服务端语句泄漏 / 连接永不归还 | defer Close |
| 11 | 字符串拼接 SQL | 注入 | 占位符 + 白名单表名列名 |
| 12 | 大结果集一次性读入内存 | OOM | 分页 / WHERE id > last_id LIMIT n 游标 / 限制 SELECT 列 |
| 13 | 用 Query 执行写语句(或反之) |
语义错、连接状态错 | 写用 Exec |
| 14 | time.Time 不开 parseTime(MySQL) |
Scan 失败 | DSN 加参数 |
| 15 | 依赖 LastInsertId(PG) |
报错 | RETURNING id |
| 16 | 在 Rows 未关闭时用同一 *DB 的事务改被锁行 | 互相死锁 | 缩小事务与迭代窗口;迭代中需要写 → 先物化再写 |
18.2 最佳实践清单
-
一个进程一个
*DB,Open+Ping后配置四大参数;defer db.Close()主要用于测试与短命工具。 -
永远优先
xxxContext版本 ;非 ctx 版本内部用context.Background(),线上无法超时兜底。 -
QueryRow 三步 :QueryRow → Scan →
errors.Is(err, sql.ErrNoRows)分支。 -
Rows 四件套 :
defer Close→for Next→Scan→Err。 -
事务三件套 :
BeginTx→defer Rollback→ 成功路径Commit。 -
生产把
db.Stats()接入指标:WaitCount/WaitDuration 是容量告警的第一信号。 -
结构体落库统一走
Valuer/Scanner,禁止在业务层手拼 JSON 字符串。 -
长事务、事务内 RPC、事务内 sleep 一律禁止;事务只包住"必要写集"。
-
迁移 SQL、批量导入等重活用驱动专属通道(pgx
CopyFrom、mysqlLOAD DATA LOCAL)而非通用接口。
19. 性能优化
-
连接池参数是第一杠杆 (见 6.7)。压测观察
Stats():-
WaitCount增长快 → 加 MaxOpen 或查慢 SQL(通常后者); -
MaxIdleClosed大 → MaxIdle 偏小; -
拨号频繁(数据库侧连接数锯齿)→ Lifetime 偏短或 Idle 偏小。
-
-
批量化 :多值 INSERT;
INSERT ... SELECT;pgxCopyFrom。千行级导入差距可达两个数量级。 -
驱动快路径 :MySQL 开
interpolateParams=true(客户端插值,省 Prepare 往返,注意超大参数与边缘转义);pgx 直接用扩展协议绑定。 -
Stmt 的正确打开方式:Tx/Conn 内高频同型语句 → 收益确定;DB 级全局 stmt → 注意它会在每条接触的连接上都 Prepare 一次,池很大时不一定赚。
-
减少 Scan 目标转换开销 :列类型与 Go 类型对齐(int64↔BIGINT);超大 BLOB 用
RawBytes且即取即用;SELECT *改显式列。 -
游标式分页 替代
LIMIT offset:避免深翻页的全量扫描。 -
超时分层 :单语句 < 事务 < 请求预算,任何一层都不要裸
context.Background()。 -
只读查询路由 :
ReadOnly: true的 TxOptions 可让驱动走只读节点(需中间件/数据库支持)。
20. API 速查表
DB
Go
Open(driver, dsn) (*DB, error) OpenDB(connector) *DB
Drivers() []string Register(name, driver) // 驱动侧
(db) Ping/PingContext
(db) Close
(db) SetMaxOpenConns(n) SetMaxIdleConns(n)
(db) SetConnMaxLifetime(d) SetConnMaxIdleTime(d)
(db) Stats() DBStats
(db) Driver() driver.Driver
(db) Exec/ExecContext → Result
(db) Query/QueryContext → *Rows
(db) QueryRow/QueryRowContext → *Row
(db) Prepare/PrepareContext → *Stmt
(db) Begin/BeginTx(opts) → *Tx
(db) Conn(ctx) → *Conn // 借出单连接
Tx(事务期内独占一条连接)
Go
(tx) Commit / Rollback
(tx) Exec/ExecContext Query/QueryContext QueryRow/QueryRowContext
(tx) Prepare/PrepareContext (tx) Stmt/StmtContext(db级stmt)
Conn(单连接会话)
Go
(c) Close // 归还;此后 ErrConnDone
(c) PingContext (c) Raw(func(driverConn any) error)
(c) Exec/Query/QueryRow/Prepare 的 Context 版 (c) BeginTx
Stmt
Go
(s) Close
(s) Exec/ExecContext Query/QueryContext QueryRow/QueryRowContext
Rows / Row
Go
(rows) Next() bool Scan(...any) Err() Close()
(rows) Columns() ColumnTypes() NextResultSet()
(row) Scan(...any) Err()
Result / ColumnType / 其它
Go
Result: LastInsertId() RowsAffected()
ColumnType: Name Length DecimalSize Nullable ScanType DatabaseTypeName
sql.Named(name, v) sql.Out{Dest,In}
sql.Null[T] / NullString / NullInt64 / ... / NullTime
sql.RawBytes
接口: sql.Scanner{Scan(src) error} driver.Valuer{Value() (Value, error)}
哨兵: sql.ErrNoRows sql.ErrTxDone sql.ErrConnDone
driver.ErrBadConn driver.ErrSkip