database/sql 与 driver 接口分层
database/sql 不是某个数据库的客户端,而是**“连接池 + 驱动接口"的通用抽象**:driver 包定义契约,各厂商给实现;你调的 *sql.DB 是自带连接池的调度器。看懂这层,才解释得清"Open 成功但连不上"“事务丢更新"“连接数会乘实例数"这些事故。本章示例全部基于自写的最小假驱动(第 12 节给出完整可运行实现;它只实现 driver.Conn 的必需方法与 ConnBeginTx,不实现 QueryerContext 等快路径接口,所以查询走 Prepare→Stmt.Query 慢路径——正是第 2 节断言机制的现场演示);生产环境把驱动换成 modernc.org/sqlite、go-sql-driver/mysql、jackc/pgx 的 stdlib 适配层即可,正文代码不用动。所有错误文本与数字均为 go1.27.0 / Windows 实测;依赖真实驱动的行为标注"取决于驱动,未在本地复现”。
注意:几个常被写错的事实,已本地核实。
database/sql/null包不存在(go doc database/sql/null报not in std);泛型sql.Null[T]是 Go 1.22 引入(GOROOTapi/go1.22.txt#60370);Go 1.27 的新增是sql.ConvertAssign、driver.RowsColumnScanner、driver.ScanContext(#67546);MarshalValuer在 go1.27.0 的 GOROOT 源码里零命中,自定义类型入库前的转换仍只有driver.Valuer一条路。
1. *sql.DB 不是连接,是连接池
这是本章最重要的一句话,后面所有坑几乎都是它的推论。源码文档原话:
“DB is a database handle representing a pool of zero or more underlying connections. It’s safe for concurrent use by multiple goroutines.”
Open 是惰性的:不拨号、不校验 DSN,官方注释:“Open may just validate its arguments without creating a connection to the database. To verify that the data source name is valid, call DB.Ping.” 实测(假驱动对一个必被拒绝的 DSN 返回错误):sql.Open("fake","bad") 返回 err=<nil> 且驱动侧建连计数为 0;PingContext 才爆错,且错误原样透传(fake: server refused connection,errors.Is 可判驱动自己的哨兵错误);第一次查询执行时建连计数才 0→1。所以服务启动必须显式 PingContext 探活,否则配错要等第一个请求才暴露。
DB 应当进程级长生命周期:DB.Close 的注释直接写了意图——“It is rare to Close a DB, as the DB handle is meant to be long-lived and shared between many goroutines.” 每个 DB 维护自己独立的池,实测新建 3 个 DB 句柄各跑一次查询,新开 3 条连接,句柄之间零复用。“每请求 Open 一个 DB"等于放弃连接池,还要叠加 TCP 握手、认证、TLS 协商的每请求开销。从 DB 发查询,包内部"借连接→执行→还连接”,并发控制全发生在池层——这也引出事务的必要性:事务的本质是把操作钉死在一条连接上(第 4 节)。
2. driver 接口分层:核心契约 + 可选能力
先钉死一个事实:标准库只定义接口,不含任何驱动实现——Open 的注释原文 “No database drivers are included in the Go standard library."。sql 与驱动之间的契约在 database/sql/driver,它是核心接口 + 可选能力接口的演化结构:database/sql 在运行时对驱动对象做类型断言,实现了哪个能力接口就启用哪条快路径。包文档:“The driver interface has evolved over time. Drivers should implement Connector and DriverContext interfaces”,并且 “All Conn implementations should implement the following interfaces: Pinger, SessionResetter, and Validator”。
分层速记:driver.Driver.Open 造出 driver.Conn(一条真连接);Conn 给 Begin/Prepare/Close;Stmt/Rows/Result/Tx 是连接内的四类协议对象;边界上的数据只走 driver.Value——允许集合是 int64、float64、bool、[]byte、string、time.Time 加 nil,其他类型必须由写入方实现 driver.Valuer、读取目标实现 sql.Scanner(第 6 节)。
对使用者的意义:Go 侧行为的差异多半是"驱动实现了哪些可选接口"的差异——不实现 QueryerContext,查询走 Prepare→Query→Close 慢路径;不实现 ConnBeginTx,非默认隔离级别直接报错(第 4 节);不实现 Validator/SessionResetter,事务取消时连接被整条丢弃(第 10 节)。sql.Register 进程内注册一次,sql.Drivers() 可查已注册名字。
3. 连接池参数:默认值与生产必设
四个 setter 的默认值全部来自源码注释:SetMaxOpenConns 默认 0=不限(“If n <= 0, then there is no limit… The default is 0 (unlimited)");SetMaxIdleConns 默认 2(“currently 2. This may change in a future release”);SetConnMaxLifetime 与 SetConnMaxIdleTime(后者 Go 1.15 引入,api/go1.15.txt)默认 0=不按时间关闭。
默认值"不限"最危险。 实测默认参数下 10 路并发查询直接新开 10 条连接(WaitCount=0,无排队)。不限的池会把数据库当无限资源:上游流量放大时连接数先撞数据库 max_connections,整库拒绝新连接,故障从你的服务扩散到共用这个库的所有服务。
实测 SetMaxOpenConns(2) 跑 10 路并发(每条查询假驱动耗时 100ms),中途采样 db.Stats():
Open=2 InUse=2 Idle=0 WaitCount=8 WaitDuration=200ms第 3 个以后的 goroutine 在池上排队;全部结束后 WaitCount=8、WaitDuration=2.01s、Idle=2。WaitCount/WaitDuration 是容量告警信号:稳态增量应接近 0,持续上涨说明池小了或查询变慢(监控接法见第 12、13 节)。
MaxIdleConns 管"还池后留几条备用”。实测不限 MaxOpen 时 4 路并发用完归池:池只留 2 条,驱动侧 Close 计数 +2、Stats().MaxIdleClosed=2;之后顺序 5 次查询新开连接数为 0(全部复用 idle 池)。“MaxIdle 远小于 MaxOpen"的代价是波峰过后拆连接、下个波峰重新拨号握手。另注意官方注释的钳制规则:MaxIdleConns 设得比 MaxOpenConns 大时会被自动压到相等。
为什么生产必须设 SetConnMaxLifetime:默认不过期的连接会死于云数据库/LB 的空闲断连、主从切换、服务端 wait_timeout——Go 拿到的是"服务端已经单方面关掉"的死连接。主动设一个短于中间设备超时的寿命(典型 5–30 分钟),让包在归还时检查年龄:实测 MaxLifetime=100ms、两次查询间隔 150ms,第一条连接被记 MaxLifetimeClosed=1 关闭,第二次查询用的是新开连接。
SetConnMaxIdleTime 的回收是后台 cleaner 周期任务,源码 connectionCleaner 写死 const minInterval = time.Second:实测 idle 超时 80ms、空闲 200ms 后连接仍在池里(Idle=1、MaxIdleTimeClosed=0)。官方措辞 “Expired connections may be closed lazily before reuse”,这个 may 是真的。
4. 事务:为什么必须绑定单个连接
Begin/BeginTx 从池里独占一条连接直到 Commit/Rollback,这是 SQL 语义的要求:隔离级别、会话变量、临时表、行锁都挂在那条连接上。源码 DB 注释原话:“Once DB.Begin is called, the returned Tx is bound to a single connection. Once Tx.Commit or Tx.Rollback is called, that transaction’s connection is returned to DB’s idle connection pool.” 实测(假驱动让 SELECT conn_id 返回执行它的连接编号):tx 内两次查询 conn_id=1,1,而 tx 持有时 db 上的查询落在 conn_id=2。
由此得到最硬的纪律:事务期间要参与原子性的语句必须用 tx.XxxContext;对同一个 db 调用 Exec/Query 走的是别的连接,根本不在这个事务里——tx 回滚了,db.Exec 也已经生效。
defer tx.Rollback() 惯用法
Tx 文档:“After a call to Tx.Commit or Tx.Rollback, all operations on the transaction fail with ErrTxDone.” 所以取到 tx 后立刻 defer tx.Rollback():正常路径 Commit 成功后 defer 的 Rollback 返回 sql.ErrTxDone(实测文本 sql: transaction has already been committed or rolled back,errors.Is 可判),无害;异常路径也有兜底。
隔离级别、只读与驱动支持
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted, ReadOnly: true})sql.IsolationLevel 常量共八个:LevelDefault/LevelReadUncommitted/LevelReadCommitted/LevelWriteCommitted/LevelRepeatableRead/LevelSnapshot/LevelSerializable/LevelLinearizable,String() 给出 "Read Committed"、"Serializable" 这类可读形式。实测 LevelSerializable 数值 6,到达驱动侧 driver.TxOptions.Isolation 同为 6,ReadOnly=true 原样透传。
注意:
Begin的注释说默认隔离级别 “dependent on the driver”;驱动不实现driver.ConnBeginTx时传非默认级别会直接报错,实测文本sql: driver does not support non-default isolation level。
BeginTx 的 ctx 语义(纠正两个流行说法)
文档原话:“The provided context is used until the transaction is committed or rolled back. If the context is canceled, the sql package will roll back the transaction.” 源码 beginDC 以 go tx.awaitDone() 兑现。实测取消 ctx 后,tx.Exec 返回 ErrTxDone——事务确已被自动回滚;连接去留分两种:驱动实现 Validator+SessionResetter 时 Open=1 Idle=1(保留回池),不实现时 Open=0、驱动侧 Close 计数 +1(整条丢弃)。源码依据是 beginDC 里 keepConnOnRollback = hasSessionResetter && hasConnectionValidator 这行。另外 Tx.Commit 返回错误时不要假设事务已回滚——标准库对 Commit 失败后的服务端状态不作任何承诺(Commit 的文档只有一句话),是否真的提交取决于驱动与协议;重试前先确认业务幂等。
5. sql.ErrNoRows:查不到不是错误
sql.ErrNoRows 实测文本 "sql: no rows in result set"。两种 API 语义不同:QueryRow(...).Scan(...) 在空结果集时返回它(实测 errors.Is 与直接 == 均可判,推荐 errors.Is,对包装链更稳);而 Query 拿到空结果集全程不报错——判空靠 Rows.Next() 第一次返回 false 且 Rows.Err() 为 nil(实测)。
陷阱:把
ErrNoRows记 ERROR 日志是最常见的日志污染——缓存 miss、关联记录不存在都是正常路径。错误分类写法见../01-基础核心包/errors-and-log.md:switch { case errors.Is(err, sql.ErrNoRows): ...; case err != nil: ... }。另注意QueryRow是延迟错误设计:查询问题要到Scan才暴露,Row.Err()(Go 1.15 加入)可提前检查。
6. NULL 的接收:Null 系列、指针与 Valuer/Scanner
string 直接接收 NULL 的真实报错(实测):
sql: Scan error on column index 0, name "x": converting NULL to string is unsupported四条可选路:
var ns sql.NullString // 经典套餐;注意字段名是 String 不是 V
var np *string // 实测:NULL 得 nil,非 NULL 指向新值——最贴合"可空列"
var ng sql.Null[int64] // Go 1.22 起的泛型版,字段 V/Valid;T 应为 driver.Value 接受的类型
var nb []byte // NULL 的文本列直接收进 []byte,实测得 nil实测 sql.Null[string] 收 NULL Valid=false;Null[int64] 能把驱动返回的 []byte("42") 扫成 V=42(扫描层宽松转换)。NullBool/NullByte/NullFloat64/NullInt16/NullInt32/NullTime 是泛型之前的固定套餐。
Valuer/Scanner 一对是自定义类型的出入库通道:写入时包调 driver.Valuer.Value(),产物必须落进 driver.Value 六种类型;读取时目标若实现 sql.Scanner,driver.Value 交给它的 Scan(src any)。Scanner 注释里有关键一句:“Reference types such as []byte are only valid until the next call to Scan and should not be retained… If retention is necessary, copy their values before the next call to Scan.” 这也预告了第 7 节的 RawBytes。实测 JSONMap 一对接口:写入时驱动侧收到 string({"k":1}),读取 []byte 无损还原;普通 Go 参数走默认转换器,int(42) 到驱动侧变 int64(42)。
7. Rows 的三条纪律:Close、Err、RawBytes
Query 返回的 *Rows 占着一条连接,三条纪律分别是"还连接、别吞错、别留引用”。
纪律一:defer Close。 实测两种路径:for rows.Next() 完整走完,EOF 时包内部自动归池(Stats 显示 InUse=0 Idle=1,不显式 Close 也不漏);但提前 break 时连接一直被占(实测 break 后 InUse=1,显式 Close() 后才变 Idle=1)。“只取第一行就走"是真实需求,所以无脑 defer rows.Close() 唯一可靠。
纪律二:迭代后查 Rows.Err()。 Next 文档:“returns true on success, or false if there is no next result row or an error happened while preparing it. Rows.Err should be consulted to distinguish between the two cases.” 实测游标中断(假驱动第 3 行处报错):迭代"正常"结束数出 2 行,rows.Err()=fake: cursor exploded mid-scan——不查就把游标故障当成"数据只有两行”。
纪律三:sql.RawBytes 出了本次迭代就是垃圾。 定义注释:“holds a reference to memory owned by the database itself. After a Rows.Scan into a RawBytes, the slice is only valid until the next call to Rows.Next, Rows.Scan, or Rows.Close.” 实测用复用内部缓冲区的假驱动(真驱动普遍如此):
第 1 行读时 "first-payload",第 2 行读时 "second-payload"
迭代结束后再读保存的第 1 行 RawBytes: "second-payloa" —— 内容被覆盖、按旧长度截断
当场 copy 到独立切片: "first-payload", "second-payload" —— 正确默认 Scan 进 string/[]byte 时包会替你拷贝;只有显式用 RawBytes 优化内存时责任才归你。另外 Rows.ColumnTypes() 拿列元信息(Name/DatabaseTypeName/ScanType/Length/Nullable),信息量取决于驱动是否实现 RowsColumnTypeXxx 系列接口:假驱动没实现,实测 DatabaseTypeName=""、Length 的 ok=false。
8. Prepare 的真实形态:每连接一份底层语句
网上流传"Go 的 prepared statement 跨连接会失效”——不对。GOROOT sql.go 的 Stmt 注释原文:
“If a Stmt is prepared on a Tx or Conn, it will be bound to a single underlying connection forever. … If a Stmt is prepared on a DB, it will remain usable for the lifetime of the DB. When the Stmt needs to execute on a new underlying connection, it will prepare itself on the new connection automatically.”
配套私有类型注释一句话:“connStmt is a prepared statement on a particular connection.” 即 db.Prepare 返回的 *sql.Stmt 是管理器,内部持 []connStmt——每条底层连接各一份真语句,连接没见过这条语句就在使用时自动补 Prepare。实测(MaxOpenConns=2,8 路并发跑同一 stmt):底层 Prepare 只发生 2 次,分布 conn#1:1 conn#2:1——按连接数收敛,与查询次数无关。
三条边界:db.Prepare 会立刻建连(实测计数 0→1),坏 DSN 在这步就炸,可当比 Ping 更早的探活;tx.Stmt(dbStmt) 绑到事务连接(实测复用其已有 connStmt,Prepare 新增 0),Tx 文档与实测确认 Commit/Rollback 后 Tx.Prepare/Tx.Stmt 产出的语句作废,再用报 sql: statement is closed;Conn.PrepareContext 绑 *sql.Conn,Conn.Close 只是归还池(底层连接不断),但绑在它上面的 Stmt 实测立即报 sql: connection is already closed——“永久绑定"绑的是 Conn 句柄。
什么时候不该用 Prepare:只执行一次的语句,Prepare 多一次协议往返(MySQL/PG 各多一个 round trip——取决于驱动,未在本地复现);带参数的点查直接 QueryRowContext,多数驱动有 QueryerContext 快路径根本不 Prepare。值得 db.Prepare 的是"热点语句 + 每条连接反复执行”。
9. context 全链路
Go 1.8/1.9 起每个方法都有 XxxContext 版本(BeginTx、PingContext 为 1.8,Conn 一族为 1.9,api/go1.8.txt/go1.9.txt 核实):ExecContext/QueryContext/QueryRowContext/PrepareContext,Stmt/Conn 同样成套。无后缀版本内部用 context.Background;生产一律带 ctx——超时与取消要穿过"池排队→建连→执行"全链路(../02-并发与同步/context.md)。
实测纠正两点。取消一次查询不会关掉那条连接(对守规矩的驱动):假驱动尊重 ctx,100ms 超时取消 5s 慢查询后,Scan 返回 context deadline exceeded(errors.Is 可判,原样透传,别当数据库故障盲目重试);随后 Stats 为 Open=1 InUse=0 Idle=1、驱动侧 Close 计数新增 0——连接经校验照常回池。真正"取消时连接被丢弃"的场景是事务(第 4 节)。取消不能穿透不合作的驱动:包文档第一句 “Drivers that do not support context cancellation will not return until after the query is completed”——驱动不响应 ctx 时,取消只是让你提前拿到错误,服务端继续跑、连接被占满。传入已取消的 ctx 则在动池子之前就快速失败(实测 context canceled)。
10. 连接复用的隐患:死连接、Validator 与两个过期时机
池化的代价:Go 认为活的连接,服务端可能早就断了(LB 空闲断连、防火墙、wait_timeout、主从切换)。Go 侧的防线是三件事叠加。
driver.Validator(Go 1.15)与 driver.SessionResetter。 驱动包文档:“If a Conn implements Validator, then the IsValid method is called before returning the connection to the connection pool. If an entry in the connection pool implements SessionResetter, then ResetSession is called before reusing the connection for another query.” 时序:还池时 IsValid() 把关,不合法按 driver.ErrBadConn 关闭;取用时若被标记需重置则调 ResetSession()。driver.ErrBadConn 是整套机制的信号量,文档规定只能在连接已不可用时从 Validator/SessionResetter/查询方法返回——它触发包层"换连接重试”,业务错误绝不能误用它。
两个过期参数检查时机不同。 SetConnMaxLifetime 在归还时检查(源码注释:“This prioritizes giving a valid connection to a client over the exact connection lifetime”,取用路径对缓存连接也再查一次);SetConnMaxIdleTime 只由后台 cleaner 处理、最短 1 秒一跳(第 3 节实测)。对"空闲即被中间设备切断”,兜底的是 lifetime + Validator,别指望 idleTime。主流真实驱动都实现了这两个接口(取决于驱动,未在本地复现行为细节)。
sql.Conn:手动取连接、必须还。 文档:“Conn represents a single database connection rather than a pool … Prefer running queries from DB unless there is a specific need for a continuous single database connection.” 用于"多条语句同一连接但不需事务":SET 会话变量、Raw() 拿底层 driver.Conn。纪律是必须 Close(还池,不断线):实测 MaxOpenConns=1 时手动持有一条,另一路 db.ExecContext 卡到 WaitCount=1,Conn.Close 后放行;Close 后再用报 sql: ErrConnDone(文本 sql: connection is already closed)。
11. Go 1.27:ConvertAssign 与 RowsColumnScanner
两条新增都面向驱动作者——go doc sql.ConvertAssign 自己就把定位写明了:“ConvertAssign is intended for use by driver implementations. Most users should not need to use it directly."。签名 sql.ConvertAssign(scanCtx driver.ScanContext, dest any, src driver.Value) error 把 Rows.Scan 内部的转换(NULL→零值/指针、[]byte 解析数字、Scanner 委托)开放给驱动复用;driver.RowsColumnScanner(NextRow+ScanColumn)允许驱动按列直接写进用户目标、跳过 driver.Value 装箱,注释补了兼容语义:“As of Go 1.27, database/sql will not call Next if a Rows implements RowsColumnScanner.”
业务侧实测其转换行为(可当 Rows.Scan 语义的权威参考):
ConvertAssign []byte("42") -> *int64 : n=42, err=<nil>
ConvertAssign nil -> **string : ptr==nil(NULL 表达为 nil 指针)
ConvertAssign "abc" -> *int : converting driver.Value type string ("abc") to a int: invalid syntax
ConvertAssign nil -> *Null[int64] : Valid=false12. 完整可运行骨架
单文件程序 = “这一章怎么写进生产”:假驱动 + 启动探活 + 池参数 + 带 ctx 查询与 ErrNoRows 分类 + 事务 defer Rollback + WaitCount 监控。实测输出:
getUser(1): name=alice age=30 err=<nil>
getUser(404): name="" age=0 err=<nil>(查不到 != 故障)
transfer ok
transfer(a->bad): err=target account invalidpackage main
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"log"
"strings"
"time"
)
// ---------- 最小假驱动:生产环境删掉本段,import 真实驱动即可 ----------
type fakeDriver struct{}
func (fakeDriver) Open(dsn string) (driver.Conn, error) {
if dsn == "" {
return nil, errors.New("fake: empty dsn")
}
return &fakeConn{}, nil
}
type fakeConn struct{}
func (*fakeConn) Prepare(q string) (driver.Stmt, error) { return &fakeStmt{q: q}, nil }
func (*fakeConn) Close() error { return nil }
func (*fakeConn) Begin() (driver.Tx, error) { return &fakeTx{}, nil }
func (*fakeConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
return &fakeTx{}, nil // 实现 ConnBeginTx,才支持传隔离级别
}
type fakeTx struct{ done bool }
func (t *fakeTx) Commit() error { t.done = true; return nil }
func (t *fakeTx) Rollback() error { t.done = true; return nil }
type fakeStmt struct{ q string }
func (s *fakeStmt) Close() error { return nil }
func (s *fakeStmt) NumInput() int { return strings.Count(s.q, "?") }
func (s *fakeStmt) Exec(args []driver.Value) (driver.Result, error) {
if strings.HasPrefix(s.q, "BOOM") {
return nil, errors.New("fake: constraint violation")
}
return driver.RowsAffected(1), nil
}
func (s *fakeStmt) Query(args []driver.Value) (driver.Rows, error) {
id := "?"
if len(args) > 0 {
id = fmt.Sprint(args[0])
}
if strings.HasPrefix(s.q, "SELECT name, age FROM users WHERE id") {
if id == "404" {
return &fakeRows{}, nil // 查不到
}
return &fakeRows{cols: []string{"name", "age"}, data: [][]driver.Value{{"alice", int64(30)}}}, nil
}
return &fakeRows{cols: []string{"result"}, data: [][]driver.Value{{"ok"}}}, nil
}
type fakeRows struct {
cols []string
data [][]driver.Value
pos int
}
func (r *fakeRows) Columns() []string { return r.cols }
func (r *fakeRows) Close() error { return nil }
func (r *fakeRows) Next(dest []driver.Value) error {
if r.pos >= len(r.data) {
return io.EOF
}
copy(dest, r.data[r.pos])
r.pos++
return nil
}
// ---------- 业务侧 ----------
type App struct{ db *sql.DB }
func NewApp(dsn string) (*App, error) {
db, err := sql.Open("fake", dsn) // 生产:换成真实驱动名
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
// 池参数:按"实例数 × MaxOpenConns < 数据库 max_connections × 0.8"倒推
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20) // 与 MaxOpenConns 相等,避免连接开了又关
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
// Open 不拨号,启动探活必须显式 Ping
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("db unreachable: %w", err)
}
go monitorPool(db)
return &App{db: db}, nil
}
func (a *App) Close() error { return a.db.Close() }
// getUser:带 ctx 的查询 + ErrNoRows 分类(查不到不是故障)。
func (a *App) getUser(ctx context.Context, id any) (name string, age int64, err error) {
err = a.db.QueryRowContext(ctx, "SELECT name, age FROM users WHERE id = ?", id).
Scan(&name, &age)
switch {
case errors.Is(err, sql.ErrNoRows):
return "", 0, nil
case err != nil:
return "", 0, fmt.Errorf("query user: %w", err)
}
return name, age, nil
}
// transfer:事务绑定单连接 + defer Rollback 惯用法。
func (a *App) transfer(ctx context.Context, from, to string) error {
tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() // 提交成功后返回 ErrTxDone,无害
if _, err := tx.ExecContext(ctx, "UPDATE accounts SET bal = bal - ? WHERE id = ?", 10, from); err != nil {
return fmt.Errorf("debit: %w", err)
}
if strings.Contains(to, "bad") { // 中途失败:defer 回滚已执行的语句
return errors.New("target account invalid")
}
if _, err := tx.ExecContext(ctx, "UPDATE accounts SET bal = bal + ? WHERE id = ?", 10, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
return tx.Commit()
}
// monitorPool:WaitCount 增量是容量信号——持续增长说明池小了或查询变慢。
func monitorPool(db *sql.DB) {
t := time.NewTicker(15 * time.Second)
defer t.Stop()
prev := db.Stats()
for range t.C {
s := db.Stats()
if dw := s.WaitCount - prev.WaitCount; dw > 0 {
log.Printf("dbpool: waits=+%d waitdur=%v open=%d inuse=%d idle=%d",
dw, s.WaitDuration-prev.WaitDuration, s.OpenConnections, s.InUse, s.Idle)
}
prev = s
}
}
func main() {
sql.Register("fake", fakeDriver{})
app, err := NewApp("demo")
if err != nil {
log.Fatalf("init: %v", err)
}
defer app.Close()
ctx := context.Background()
name, age, err := app.getUser(ctx, 1)
fmt.Printf("getUser(1): name=%s age=%d err=%v\n", name, age, err)
name, age, err = app.getUser(ctx, "404")
fmt.Printf("getUser(404): name=%q age=%d err=%v(查不到 != 故障)\n", name, age, err)
if err := app.transfer(ctx, "a", "b"); err != nil {
log.Printf("transfer: %v", err)
} else {
fmt.Println("transfer ok")
}
fmt.Printf("transfer(a->bad): err=%v\n", app.transfer(ctx, "a", "bad"))
}Stats() 返回的 DBStats(Go 1.5 就有)各字段语义:MaxOpenConnections 池上限(0=不限);OpenConnections 已建立(在用+空闲);InUse 借出中(Rows 未 Close 会卡在这);Idle 池中待用;WaitCount/WaitDuration 排队次数与累计时长;MaxIdleClosed、MaxIdleTimeClosed、MaxLifetimeClosed 分别因超出 idle 数、idle 超时、寿命到期而关闭的累计数。
13. 陷阱汇总
| 陷阱 | 后果(实测/源码依据) | 正确姿势 |
|---|---|---|
每请求 sql.Open 新建 DB |
池不共享,3 句柄新开 3 连接 | 进程级单例 |
| 把 Open 成功当"能连上” | 坏 DSN 首个请求才爆(实测 err=nil) | 启动 PingContext |
MaxOpenConns 留默认 0 |
10 并发直接开 10,撞库 max_connections |
必设有限值 |
MaxIdleConns 远小于 MaxOpen |
4 并发后 Close+2,波峰重拨号 | 稳态两者相等 |
不设 SetConnMaxLifetime |
拿到 LB/服务端杀掉的死连接 | 短于中间设备超时 |
| 指望 idleTime 即时回收 | 80ms 超时 200ms 后未关(cleaner≥1s) | 兜底靠 lifetime+Validator |
事务里混用 db.Exec |
走另一条连接(实测 conn_id 1 vs 2) | 原子操作只走 tx |
忘 defer tx.Rollback() |
提前 return 泄漏独占连接 | 拿到 tx 立刻 defer |
| 把 ErrNoRows 记 ERROR | 日志噪音、误告警 | errors.Is 分支 |
Query 后不 defer Close |
break 后 InUse=1 永久占用 | 永远 defer Close |
不查 rows.Err() |
游标中断被当正常结束(实测吞错) | 迭代完必查 Err |
| RawBytes 保存跨行 | 被覆盖成 “second-payloa” | 当场 copy |
| 以为 Stmt 跨连接失效 | 与 GOROOT 注释相反,自动重备 | 热点语句放心复用 |
| 一次性语句用 Prepare | 多一次往返且立刻拨号 | 点查直接 QueryRow |
| 事务 ctx 取消当"只回滚第一条" | awaitDone 实际整个回滚 | 别假设取消后仍可 Commit |
手动 db.Conn 忘 Close |
独占连接,他人排队 WaitCount=1 | defer c.Close() |
实战建议
和 ORM 的边界。 GORM(本仓库 ../../gorm/01-核心概念.md)把本文一切重新包装:它底层同样是长生命周期 *sql.DB,池参数照样归你调——ORM 不解决连接问题。报表/批量/窗口函数/存量 SQL 迁移这类"SQL 本身就是业务规则"的场景直接写 SQL 更可控;日常 CRUD 用 ORM 省时间,但事务边界、ErrNoRows、NULL 三件事在 ORM 底下仍是本文语义,出诡异问题回到 database/sql 层排查。sqlx 补结构体映射与 NamedExec,保留裸 SQL,小项目够用;引不引取决于你愿不愿意多一个依赖换掉手写 rows.Scan。
连接数是一道乘法题。 容量关系是 实例数 × MaxOpenConns ≤ 数据库 max_connections × 0.8(留两成给运维与旁路服务)。K8s 里 HPA 把副本从 3 扩到 30,连接需求就从 60 涨到 600——库先死。反过来池太小也不省资源:排队全记进 WaitDuration,P99 直接恶化。读多写少服务的起点:MaxOpen 按单机可承受并发(核数 ×2~4 起步、压测定档),MaxIdle 与之相等,lifetime 看云厂商 LB 空闲超时(常见 350s 量级,取一半),上线后盯 WaitCount 增量调参。
慢查询先看池信号再看 SQL。 Stats() 的 WaitCount/WaitDuration 增量非零时先分"等连接"还是"查询慢":InUse≈MaxOpen 且 Idle=0 是池饱和,扩池或限上游并发;InUse 不高仍慢才是数据库侧的事。把第 12 节 monitorPool 接进 Prometheus(Go 埋点写法见 ../../prometheus/04-客户端埋点.md)对增量配告警。驱动特有错误(MySQL 死锁 1213、PG 40001)的分类重试取决于驱动,未在本章复现——用真实驱动时把它们作为 errors.Is/As 自定义分支接进错误分类层。对比阅读:./os-file.md(文件世界与连接池世界的边界)、../02-并发与同步/context.md(ctx 语义细节)、../01-基础核心包/errors-and-log.md(错误分类总法)。