Go 标准库 database/sql 详解

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):

  1. 若驱动实现了 driver.QueryerContext(或旧 Queryer),优先直接把 SQL 文本 + 参数交给驱动(驱动可在内部插值或走协议级预编译,如 MySQL 驱动的客户端插值、pgx 的扩展协议绑定);

  2. 驱动返回 driver.ErrSkip 时,退回慢路径:Prepare → Stmt.Query → Stmt.Close(一次一编译一销毁);

  3. 驱动没实现这些接口,直接走慢路径。

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
}

三个要点:

  1. Open 不建立任何真实连接 ------它只查驱动、构造 *DB、启动 connectionOpener 后台 goroutine。DSN 错误往往要到第一条 SQL 才暴露,所以惯用法是 Open 后立刻 db.Ping()。

  2. 未注册驱动时报错文案 forgotten import? 是最常见的新手问题(忘写匿名导入)。

  3. *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) // 谷底回收

⚠️ 两个经典坑:

  1. 默认 MaxIdleConns=2 + 高并发 → WaitCount/WaitDuration 暴涨、连接不停重建。这是不配置时最常见的性能问题。

  2. 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、mysql LOAD DATA LOCAL)而非通用接口。


19. 性能优化

  1. 连接池参数是第一杠杆 (见 6.7)。压测观察 Stats():

    • WaitCount 增长快 → 加 MaxOpen 或查慢 SQL(通常后者);

    • MaxIdleClosed 大 → MaxIdle 偏小;

    • 拨号频繁(数据库侧连接数锯齿)→ Lifetime 偏短或 Idle 偏小。

  2. 批量化 :多值 INSERT;INSERT ... SELECT;pgx CopyFrom。千行级导入差距可达两个数量级。

  3. 驱动快路径 :MySQL 开 interpolateParams=true(客户端插值,省 Prepare 往返,注意超大参数与边缘转义);pgx 直接用扩展协议绑定。

  4. Stmt 的正确打开方式:Tx/Conn 内高频同型语句 → 收益确定;DB 级全局 stmt → 注意它会在每条接触的连接上都 Prepare 一次,池很大时不一定赚。

  5. 减少 Scan 目标转换开销 :列类型与 Go 类型对齐(int64↔BIGINT);超大 BLOB 用 RawBytes 且即取即用;SELECT * 改显式列。

  6. 游标式分页 替代 LIMIT offset:避免深翻页的全量扫描。

  7. 超时分层 :单语句 < 事务 < 请求预算,任何一层都不要裸 context.Background()。

  8. 只读查询路由 :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
相关推荐
云边有个稻草人1 小时前
MongoDB迁移不止是数据搬家:用KingbaseES重构文档数据开发
数据库·mongodb·重构·国产数据库·信创数据库·数据库国产化·mongodb迁移
MaAidan1 小时前
让 Agent 接上 YApi、Yuque、数据库、Redis 和日志:我的 MCP 资源绑定设计
数据库·redis·缓存
pikapika~~~1 小时前
Mysql+Redis八股
数据库·redis·mysql
ShineWinsu1 小时前
对于Qt:QLCDNumber常见属性以及QTimer的解析
服务器·数据库·c++·qt·gui·qtimer·qlcdnumber
modelmd1 小时前
Go 切片、映射常见使用场景
开发语言·后端·golang
captain3769 小时前
MyBatis(入门5)其他查询操作
java·数据库·spring boot·mybatis
小此方10 小时前
Re:Mysql数据库基础篇(八):详细讲解复合查询、多表查询、自连接、多行、多列子查询与合并查询
数据库·mysql
xszivvvv10 小时前
Mariadb数据库frm文件异常,如何恢复数据
数据库·mariadb
smile_life_11 小时前
MySQL 设置 防止账户被暴力破解
数据库·mysql