1. 背景:为什么需要 database/sql
1.1 从问题出发:Go 数据库访问的三层痛点
任何语言做数据库访问都会遇到三个层次的问题,Go 在 database/sql 出现之前的生态现状如下:
- 驱动各自为政:早期 Go 数据库驱动(如 go-sqlite3、go-sql-driver/mysql、lib/pq)的 API 互不兼容------A 驱动的查询函数签名与 B 驱动完全不同,业务代码一旦选定驱动就被锁定,换库等于重写数据访问层。
- 连接管理缺失:每个驱动都要自己实现连接创建、销毁、复用。新手代码最常见的写法是"每次请求开一个新连接、用完就关",在高并发下连接风暴直接打垮数据库;而连接池、空闲超时、生命周期管理这些横切关注点,驱动各自实现质量参差不齐。
- 事务与预处理各写各的:预处理语句(防 SQL 注入 + 复用执行计划)、事务边界管理、NULL 值处理,这些是与具体数据库无关的通用能力,却被迫在每个驱动里重复实现。
1.2 database/sql 的答案:接口抽象 + 驱动注册制
Go 官方给出的方案是 database/sql 标准库,其设计核心是一句话:
sql 层负责"与数据库无关"的通用能力(连接池、预处理、事务、扫描),driver 层负责"与数据库有关"的协议细节;业务代码只依赖 sql 层的接口。
┌─────────────────────────────────────────────┐
│ 业务代码(只 import database/sql) │
│ sql.Open / db.Query / db.Exec / tx.Commit │
└──────────────────┬──────────────────────────┘
│ 通用能力:连接池 / 预处理 / 事务 / 扫描 / NULL
┌──────────────────▼──────────────────────────┐
│ database/sql(标准库,与数据库无关) │
│ DB / Conn / Tx / Stmt / Rows / Result │
└──────────────────┬──────────────────────────┘
│ driver.Driver / Conn / Stmt / Rows 接口
┌──────────────────▼──────────────────────────┐
│ 驱动层(第三方,如 go-sqlite3) │
│ 协议解析:SQLite / MySQL / PostgreSQL ... │
└─────────────────────────────────────────────┘
这套设计带来三个直接收益:
- 换库不改业务代码:DSN 与驱动名换掉,上层查询代码几乎不动(占位符语法差异除外,见 6.13)。
- 连接池免费获得:*sql.DB 自带线程安全的连接池,业务代码永远不要自己管理连接。
- 能力可协商:驱动通过实现可选接口(driver.QueryerContext、driver.ConnBeginTx、driver.ExecerContext 等)逐步获得新能力,标准库会自动探测并降级到兜底实现,老驱动不会被淘汰,新能力不用破环。
2. 核心架构与设计哲学
2.1 两个层的职责边界
| 层 | 职责 | 不负责 |
|---|---|---|
| sql 层(标准库) | 连接池管理、并发安全、事务边界、预处理语句复用、Rows 生命周期、Scan 类型转换、Context 传播、驱动注册 | 具体协议、SQL 方言、DSN 解析 |
| driver 层(第三方) | 网络协议(MySQL 握手、PostgreSQL 消息、SQLite 文件 I/O)、SQL 方言执行、类型映射到底层 | 连接池、并发安全(驱动实现通常无锁,全靠 sql 层串行化) |
关键推论:*sql.DB 是并发安全的,可以放心被多个 goroutine 共享;但驱动层的 driver.Conn 不保证并发安全,database/sql 通过"同一时刻一个连接只被一个 goroutine 使用"的借用模型规避了这一点(见 5.3)。
2.2 三个核心对象
| 对象 | 语义 | 并发安全 | 生命周期 |
|---|---|---|---|
| *sql.DB | 数据库连接池句柄,代表"一组可用连接" | 是,任意 goroutine 共享 | Open 创建,进程结束/显式 Close |
| *sql.Tx | 事务对象,绑定一个具体连接 | 否,不可跨 goroutine 并发使用 | Begin 创建,Commit/Rollback 结束 |
| *sql.Stmt | 预处理语句,内部是连接池中的一组准备语句 | 是,内部维护自己的小连接池 | Prepare 创建,Close 释放 |
| *sql.Rows | 查询结果游标,绑定一个具体连接 | 否 | Query 创建,Close/迭代完毕释放 |
2.3 懒连接语义(最容易踩的第一个坑)
sql.Open 不会建立任何数据库连接,它只做两件事:注册驱动查找 + 创建空的连接池对象。真正的连接发生在第一次实际操作(Query/Exec/Ping)时。
Go
db, err := sql.Open("sqlite3", "bad/path/that/does/not/exist.db")
// err == nil !此时根本不知道 DSN 是错的
所以线上排查数据库问题时,第一个习惯应该是:Open 之后立刻 defer db.Close() 并 db.Ping()(或 PingContext)验证连通性。
3. API 说明
3.1 驱动注册
Go
// 驱动在自己的 init() 中调用(自动执行,无需业务代码调用)
func init() {
sql.Register("sqlite3", &SQLiteDriver{})
}
业务侧通过 匿名导入 触发驱动注册:
Go
import (
"database/sql"
_ "github.com/mattn/go-sqlite3" // SQLite,CGO 实现
_ "github.com/go-sql-driver/mysql" // MySQL
_ "github.com/lib/pq" // PostgreSQL(纯 Go)
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL(pgx 官方驱动,推荐)
_ "github.com/godror/godror" // Oracle
_ "github.com/microsoft/go-mssqldb" // SQL Server
_ "github.com/taosdata/driver-go/v3/taosSql" // TDengine(见第 110 篇)
)
注意:_ 导入的包如果从未被显式引用,Go 编译器会保留其 init() 副作用但不会报"imported and not used"。这是 Go 生态注册驱动的标准姿势。
3.2 连接打开
Go
// 方式一:sql.Open ------ 按驱动名查找已注册驱动,解析 DSN
db, err := sql.Open("sqlite3", "data.db?_journal_mode=WAL&_busy_timeout=5000")
// 方式二:sql.OpenDB ------ 直接传入 driver.Connector(可自定义连接器)
connector, err := sqlite.NewConnector("data.db", &sqlite.Config{...})
db := sql.OpenDB(connector)
DSN 是驱动自定义的字符串,没有统一标准------mysql 是 user:pass@tcp(host:port)/dbname?parseTime=true,sqlite3 是文件路径加 _ 前缀参数,lib/pq 是 host=... user=... dbname=... sslmode=disable。这也是"换库不改代码"的唯一破口,需要抽一层配置。
3.3 连接池配置(四个旋钮)
Go
db.SetMaxOpenConns(100) // 最大打开连接数(含在用),默认无限制
db.SetMaxIdleConns(10) // 最大空闲连接数,默认 2
db.SetConnMaxLifetime(30 * time.Minute) // 连接最长存活时间(防数据库侧断开)
db.SetConnMaxIdleTime(5 * time.Minute) // 空闲超过该时长即关闭(Go 1.15+)
| 参数 | 默认值 | 典型生产值 | 作用 |
|---|---|---|---|
| SetMaxOpenConns | 无限制 | 数据库 max_connections 的 80% | 防止连接风暴打垮 DB |
| SetMaxIdleConns | 2 | 与 MaxOpen 相同或略小 | 减少新建连接的握手开销 |
| SetConnMaxLifetime | 无限制 | 与 DB 侧 wait_timeout 错开(如 30min) | 轮换连接,避免"半死连接" |
| SetConnMaxIdleTime | 无限制 | 5~10min | 释放空闲连接,降低 DB 侧占用 |
规则 :SetMaxIdleConns 大于 SetMaxOpenConns 时 Go 会静默钳制为 MaxOpenConns;四个参数都不是必须设的,但生产环境至少要设 SetConnMaxLifetime(理由见 6.3)。
3.4 查询执行族
Go
// 查询多行
rows, err := db.QueryContext(ctx, "SELECT id, name FROM device WHERE type = ?", 1)
// 查询单行(内部就是 Query + 第一行 + 自动 Close)
row := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM point WHERE status = ?", "active")
// 执行写操作
res, err := db.ExecContext(ctx, "INSERT INTO point(name, unit) VALUES(?, ?)", "主轴转速", "rpm")
// 预处理
stmt, err := db.PrepareContext(ctx, "UPDATE point SET value = ? WHERE id = ?")
*Context 后缀版本(QueryContext/ExecContext/PrepareContext/BeginTx)是 Go 1.8+ 推荐的默认选择------它们让超时、取消、请求级 deadline 可以贯穿整个数据库操作。无 Context 版本仅是内部包一层 context.Background(),生产代码请直接使用 Context 版本。
3.5 Rows 游标遍历(固定三步曲)
Go
rows, err := db.QueryContext(ctx, query, args...)
if err != nil { /* 1. 查询本身失败 */ }
defer rows.Close() // 2. 无论是否遍历完,必须 Close 释放连接
for rows.Next() { // 3. 逐行扫描
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil { /* 扫描失败 */ }
}
if err := rows.Err(); err != nil { // 迭代过程中发生的错误(如网络中断)
/* 处理 */
}
三个常见遗漏点:
- defer rows.Close() 忘写 → 连接被游标占住直到 GC,连接池被耗尽(见 6.1)。
- rows.Err() 忘检查 → 迭代中途出错被静默吞掉,数据不完整却当成功处理。
- rows.Scan 必须与 SELECT 列一一对应,多了少了都报 Scan error on column index。
3.6 Scan 类型映射
Scan 的目标指针类型与驱动返回的底层类型必须兼容,database/sql 会做有限的自动转换(如 int64 → int、\[\]byte → string),但不会做任意转换:
| 数据库类型 | 安全 Scan 目标 |
|---|---|
| INTEGER | int64、int、float64、\[\]byte、string |
| TEXT / VARCHAR | string、\[\]byte、sql.NullString |
| REAL / DOUBLE | float64、float32 |
| BLOB | \[\]byte |
| BOOLEAN | bool(MySQL 驱动需 parseTime 无关,SQLite 是 0/1 自动转) |
| DATETIME / TIMESTAMP | time.Time(MySQL 需 DSN 加 parseTime=true) |
| DECIMAL | string 最稳(避免精度损失)、\[\]byte;MySQL 可用 decimal 类型 |
NULL 处理:数据库返回 NULL 时,Scan 到非指针基本类型会直接报错(converting NULL to int64 is unsupported)。必须用 sql.Null* 系列或自定义 sql.Scanner(见 4.6)。
3.7 事务
Go
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted, ReadOnly: false})
if err != nil { return err }
defer tx.Rollback() // 经典模式:先 defer,Commit 成功后 Rollback 自动变 no-op
// tx 内所有操作绑定同一个连接
if _, err := tx.ExecContext(ctx, "INSERT INTO ..."); err != nil { return err }
if _, err := tx.ExecContext(ctx, "UPDATE ..."); err != nil { return err }
if err := tx.Commit(); err != nil { return err } // 成功后 defer 的 Rollback 无效
- defer Rollback 模式是 Go 事务的黄金写法:任何分支 return 都会触发 Rollback,不会泄漏事务连接;Commit 成功后再 Rollback 返回 sql.ErrTxDone,无害。
- TxOptions.Isolation 支持 sql.LevelReadUncommitted ~ LevelSerializable,驱动不支持时会返回错误。
- *sql.Tx 绑定单个连接 ,所以不可并发使用(见 6.8)。
3.8 命名参数与预处理
Go
// 命名参数(Go 1.8+,驱动需支持 sql.NamedArg 或实现 NamedValueChecker)
db.QueryContext(ctx, "SELECT * FROM point WHERE name = @name AND type = @type",
sql.Named("name", "主轴"), sql.Named("type", 1))
// 或驱动独立实现:lib/pq 用 $1,MySQL 用 ?,SQLite 用 ? 或 $N
// 命名参数的好处:参数顺序无关、可复用
3.9 统计与监控
Go
stats := db.Stats()
// stats.OpenConnections 当前打开连接数
// stats.InUse 正在使用的连接数
// stats.Idle 空闲连接数
// stats.WaitCount / stats.WaitDuration 等待连接的累计次数/时长
// stats.MaxIdleClosed / MaxLifetimeClosed 因策略关闭的连接数
db.Stats() 是排查连接池问题的第一工具,建议接入 Prometheus 指标(工业数采网关可定期上报)。
3.10 驱动能力协商接口(进阶)
database/sql 通过接口探测实现"老驱动能用、新驱动更快"的渐进式增强:
| 可选接口 | 能力 | 未实现时的兜底 |
|---|---|---|
| driver.ConnBeginTx | 原生事务 + 隔离级别 | sql 层自动 Begin 后发 SET TRANSACTION |
| driver.QueryerContext / driver.ExecerContext | 直接执行(省一次 Prepare) | sql 层 Prepare → Execute → Close |
| driver.StmtExecContext | 上下文感知的执行 | 无 ctx 语义 |
| driver.NamedValueChecker | 校验/转换命名参数 | 仅支持位置参数 |
| driver.SessionResetter | 连接归还池时重置会话 | 归还时直接销毁连接 |
| driver.Validator | 归还前验证连接活性 | 不验证(可能导致 6.3 的半死连接) |
| driver.Pinger | Ping 的驱动级实现 | 发一个空查询 |
理解这张表的意义:驱动的实现深度直接决定 database/sql 的性能与行为,选型时优先选实现了 SessionResetter + QueryerContext + ConnBeginTx 的驱动(如 pgx、go-sql-driver/mysql 新版本)。
4. 详细使用说明(8 个可编译示例)
示例统一使用 modernc.org/sqlite(纯 Go、无 CGO,工业网关交叉编译友好)或 mattn/go-sqlite3(CGO 版,功能全)。二者注册名都是 "sqlite3",二选一导入即可,示例代码通用。
4.1 示例 1:最小 CRUD 闭环(Open + Ping + 建表 + 插入 + 查询)
Go
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "modernc.org/sqlite" // 纯 Go SQLite 驱动,无 CGO
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
db, err := sql.Open("sqlite3", "device.db")
if err != nil {
log.Fatalf("open: %v", err) // 注意:这里基本不会失败
}
defer db.Close()
if err := db.PingContext(ctx); err != nil { // 真正验证连通性
log.Fatalf("ping: %v", err)
}
// 建表
_, err = db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS device (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
vendor TEXT,
enabled INTEGER DEFAULT 1
)`)
if err != nil {
log.Fatalf("create table: %v", err)
}
// 插入
res, err := db.ExecContext(ctx,
"INSERT INTO device(name, vendor, enabled) VALUES(?, ?, ?)",
"CNC-01", "FANUC", 1)
if err != nil {
log.Fatalf("insert: %v", err)
}
id, _ := res.LastInsertId()
fmt.Printf("inserted id=%d\n", id)
// 查询
rows, err := db.QueryContext(ctx,
"SELECT id, name, vendor, enabled FROM device WHERE enabled = ?", 1)
if err != nil {
log.Fatalf("query: %v", err)
}
defer rows.Close()
for rows.Next() {
var (
did int64
name string
vendor sql.NullString // vendor 可能为 NULL
enabled bool
)
if err := rows.Scan(&did, &name, &vendor, &enabled); err != nil {
log.Fatalf("scan: %v", err)
}
fmt.Printf("id=%d name=%s vendor=%v enabled=%v\n", did, name, vendor, enabled)
}
if err := rows.Err(); err != nil {
log.Fatalf("rows err: %v", err)
}
}
4.2 示例 2:连接池配置 + Stats 监控
Go
db, _ := sql.Open("sqlite3", "pool.db")
db.SetMaxOpenConns(50) // 上限 50
db.SetMaxIdleConns(20) // 空闲保留 20
db.SetConnMaxLifetime(30 * time.Minute) // 连接寿命 30 分钟
db.SetConnMaxIdleTime(5 * time.Minute) // 空闲 5 分钟即回收
// 定期输出池状态(工业网关可接到日志/监控)
go func() {
for range time.Tick(30 * time.Second) {
s := db.Stats()
log.Printf("pool: open=%d inUse=%d idle=%d wait=%d (%.2fs)",
s.OpenConnections, s.InUse, s.Idle, s.WaitCount, s.WaitDuration.Seconds())
}
}()
4.3 示例 3:预处理语句复用(防注入 + 性能)
Go
// 一次性 Prepare,循环内复用 Stmt
stmt, err := db.PrepareContext(ctx,
"INSERT INTO point(name, unit, device_id) VALUES(?, ?, ?)")
if err != nil {
log.Fatal(err)
}
defer stmt.Close() // Stmt 也占用连接池资源,必须 Close
for _, p := range points {
if _, err := stmt.ExecContext(ctx, p.Name, p.Unit, p.DeviceID); err != nil {
log.Printf("insert %s failed: %v", p.Name, err)
}
}
注意 :*sql.Stmt 内部维护一个小连接池(每个打开的连接各准备一份),所以它本身是并发安全的,但 Stmt.Close() 前所有占用的连接不会释放。不要在循环体内反复 Prepare(每轮新建 + 销毁,开销大于收益)。
4.4 示例 4:事务 + defer Rollback 黄金模式
Go
func transferPoints(ctx context.Context, db *sql.DB, deviceID int64, points []Point) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return err
}
defer tx.Rollback() // 关键:Commit 成功后这里变成 no-op
// 先更新设备状态
if _, err := tx.ExecContext(ctx,
"UPDATE device SET enabled = 0 WHERE id = ?", deviceID); err != nil {
return err // 触发 defer Rollback
}
// 批量写点位
stmt, err := tx.PrepareContext(ctx,
"INSERT INTO point(name, unit, device_id) VALUES(?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
for _, p := range points {
if _, err := stmt.ExecContext(ctx, p.Name, p.Unit, deviceID); err != nil {
return err // 任何一步失败 → 全部回滚
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
4.5 示例 5:Context 超时与取消(慢查询防护 + 优雅关闭)
Go
// 单个查询 3 秒超时
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT ... FROM big_table")
if err != nil {
// context deadline exceeded 时 err 会包含超时信息
log.Printf("query timeout: %v", err)
return
}
defer rows.Close()
// ...遍历
// 服务优雅关闭:给所有进行中的查询一个收尾窗口
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
// 把 shutdownCtx 传给所有 QueryContext/ExecContext/BeginTx
// 到点后连接池会终止等待中的请求,db.Close() 等待 in-use 连接归还
db.Close()
4.6 示例 6:NULL 处理与自定义 Scanner/Valuer
Go
// 方案一:内置 Null 类型
var (
value sql.NullFloat64 // 工业点位值:可能为 NULL(传感器未回数)
alarmStr sql.NullString
ts sql.NullTime
)
rows.Scan(&value, &alarmStr, &ts)
if value.Valid {
fmt.Printf("value=%.3f\n", value.Float64)
} else {
fmt.Println("value is NULL")
}
// 方案二:自定义 Scanner / Valuer ------ 处理数据库没有原生类型的场景
// 例:把数据库 TEXT "on/off" 映射为 bool
type SwitchState bool
func (s *SwitchState) Scan(src any) error {
switch v := src.(type) {
case []byte:
*s = string(v) == "on"
return nil
case string:
*s = v == "on"
return nil
default:
return fmt.Errorf("unsupported SwitchState source: %T", src)
}
}
func (s SwitchState) Value() (driver.Value, error) {
if s {
return "on", nil
}
return "off", nil
}
// 使用:Scan(&state) 与 Exec(..., state) 都会自动走自定义转换
实现要点:sql.Scanner 是 Scan(dest any) error,driver.Valuer 是 Value() (driver.Value, error)。driver.Value 只允许 int64/float64/bool/\[\]byte/string/time.Time/nil 七种类型,自定义 Valuer 必须落到这七种之一。
4.7 示例 7:与 GORM 共存(拿到底层 *sql.DB)
Go
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
gdb, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
// GORM 内部持有 *sql.DB,显式暴露
sqlDB, err := gdb.DB() // 返回 *sql.DB(连接池句柄)
if err != nil {
log.Fatal(err)
}
sqlDB.SetMaxOpenConns(20)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
// 之后 GORM 自动方法与原生 database/sql 可以混用同一连接池
var count int64
sqlDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM devices").Scan(&count)
gdb.Model(&Device{}).Count(&count)
反向也成立:任何基于 database/sql 的库(sqlx、ent 的 sql 后端等)都能通过类似方式共享连接池。
4.8 示例 8:工业数采场景------采集结果批量落库
贴合本系列工业数采主线的完整闭环示例(设备点位 → 采集 → 批量入库 → 查询最近状态):
Go
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
)
type Sample struct {
PointID int64
Value float64
Quality int // 0=正常 1=超限 2=通信中断
}
// 批量落库:单事务 + 预处理 + 多值批量,兼顾速度与一致性
func flushSamples(ctx context.Context, db *sql.DB, samples []Sample) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
"INSERT INTO sample(point_id, value, quality, ts) VALUES(?, ?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
now := time.Now()
for _, s := range samples {
if _, err := stmt.ExecContext(ctx, s.PointID, s.Value, s.Quality, now); err != nil {
return err
}
}
return tx.Commit()
}
// 最近 1 分钟每点位最新值(时序查询典型模式)
func latestByPoint(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `
SELECT point_id, value, quality, ts
FROM sample s
WHERE ts = (SELECT MAX(ts) FROM sample WHERE point_id = s.point_id)
AND ts >= datetime('now', '-1 minute')
ORDER BY point_id`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var (
pid int64
value float64
q int
ts time.Time
)
if err := rows.Scan(&pid, &value, &q, &ts); err != nil {
return err
}
fmt.Printf("point=%d value=%.3f quality=%d ts=%s\n", pid, value, q, ts)
}
return rows.Err()
}
5. 底层实现剖析:连接池与 driver 接口
5.1 driver 接口体系(最小骨架)
Go
// 最核心的三个接口(老驱动只需要实现这三个)
type Driver interface {
Open(name string) (Conn, error) // 建立一条物理连接
}
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}
type Stmt interface {
Close() error
NumInput() int
Exec(args []Value) (Result, error)
Query(args []Value) (Rows, error)
}
database/sql 把这三个接口的能力全部包一层:它自己维护连接池、自己的 Stmt 池、自己的事务状态机,驱动只需要做最原始的"发一条 SQL 过去"。
5.2 DB 结构:池的核心状态
Go
type DB struct {
// 连接池三件套
freeConn []*driverConn // 空闲连接队列(归还的)
connRequests map[uint64]chan connRequest // 等待连接的请求队列(chan 实现阻塞等待)
openerCh chan struct{} // 异步建连接用的信号通道
// 计数
numOpen int // 已创建的物理连接数
maxOpen int // SetMaxOpenConns 值,0 表示无限制
maxIdle int // SetMaxIdleConns 值
...
mu sync.Mutex // 保护上述状态(DB 并发安全的根基)
...
}
5.3 连接获取/归还的完整语义
goroutine A 调用 db.QueryContext
│
▼
1. 加锁,检查 freeConn 是否有空闲连接
│
├─ 有 → 弹出空闲连接,标记 inUse,解锁,直接使用
│
└─ 无 → 检查 numOpen 是否达到 maxOpen
├─ 未达上限 → numOpen++,解锁,异步/同步创建新物理连接
└─ 已达上限 → 把请求注册到 connRequests(带 channel),
解锁,阻塞等待归还/超时
▲
│ 归还时
goroutine B 用完连接(Close/遍历完)
│
▼
2. 加锁,检查 connRequests 是否有等待者
├─ 有 → 直接把该连接交给等待者(不经过 freeConn)
└─ 无 → 若空闲数 < maxIdle 则放入 freeConn;否则销毁物理连接
三个关键设计决策:
- 连接永远是"单占用":一个物理连接同一时刻只被一个 goroutine 使用,驱动层因此可以完全无锁。
- 等待用 channel 而非轮询:connRequests 的阻塞等待天然支持 Context 取消(select done channel)。
- 归还时优先直传等待者:减少连接在 freeConn 中"过一手"的切换开销。
5.4 上下文感知与连接池回收
- QueryContext 的 ctx 取消时,正在等待连接的请求立即返回 context.Canceled;已经拿到连接的请求由驱动层(实现了 driver.QueryerContext 时)中断底层 I/O。
- 连接归还时若 ConnMaxLifetime 已过期,直接销毁而不是入池(MaxLifetimeClosed 计数 +1)。
- 实现了 driver.SessionResetter 的驱动,归还时调用 ResetSession 清掉事务残留/临时表,连接可安全复用;未实现则该连接归还时直接销毁(性能差异显著,选型时留意)。
6. 常错点 / 坑(22 条)
6.1 Rows 忘记 Close → 连接池耗尽(最高频)
Go
rows, _ := db.Query("SELECT ...")
// 忘写 defer rows.Close(),或循环提前 break 忘了 Close
// → 该物理连接一直被 Rows 占用,连接池逐渐枯竭,业务越来越慢直到全部阻塞
解法:defer rows.Close() 紧跟 Query;确认遍历完也要 Close(Rows 会在迭代完自动关闭底层连接,但显式 Close 才是确定性行为)。
6.2 Scan 到 NULL 报错
Go
var value float64
rows.Scan(&value) // 数据库该列是 NULL → error: converting NULL to float64 is unsupported
解法:用 sql.NullFloat64/sql.NullString/sql.NullTime 或自定义 Scanner(见 4.6)。
6.3 未设 SetConnMaxLifetime → 半死连接
MySQL 默认 wait_timeout=8h,连接空闲超过该时间会被服务端踢掉;客户端连接池里的旧连接毫不知情,下次使用报 invalid connection 或 connection reset。 解法:SetConnMaxLifetime(30 * time.Minute),比数据库侧超时小即可;同时 DSN 侧 MySQL 可加 timeout 参数。SQLite 本地文件一般无此问题,但 WAL 模式建议设 _busy_timeout。
6.4 sql.Open 不报错就以为连接成功
Go
db, _ := sql.Open("sqlite3", "/bad/path/x.db") // err == nil
db.Query("SELECT 1") // 这里才报错
解法:Open 后立即 PingContext(见 4.1)。
6.5 忘了匿名导入驱动 → sql: unknown driver
Go
import "database/sql"
// 忘了 _ "github.com/go-sql-driver/mysql"
db, err := sql.Open("mysql", dsn) // err: sql: unknown driver "mysql" (forgotten import?)
解法:确认驱动包被匿名导入;sql.Open 的驱动名必须与驱动 init() 注册名完全一致(sqlite3/mysql/postgres/taosSql 等)。
6.6 事务中提前 return 忘 Rollback
Go
tx, _ := db.Begin()
tx.Exec("INSERT ...")
return nil // 没 Commit 也没 Rollback → 连接一直被事务占着,锁不释放
解法:黄金模式 defer tx.Rollback()(见 4.4);Commit 后 defer 自动失效。
6.7 循环内反复 Prepare
Go
for i := 0; i < 10000; i++ {
stmt, _ := db.Prepare("INSERT ...") // 每轮新建 + 每轮 Close
stmt.Exec(...)
stmt.Close()
}
解法:循环外 Prepare 一次,循环内 Exec 复用(见 4.3)。批量写优先用单事务 + 单 Stmt。
6.8 共享 *sql.Tx / *sql.Rows 到多个 goroutine
*sql.DB 并发安全,但 *sql.Tx、*sql.Rows、*sql.Stmt(单连接场景)不是 。多个 goroutine 共用一个 Tx 会触发 database/sql: Tx is already closed 或数据错乱。 解法:Tx 内串行执行;需要并行写就拆成多个 Tx(各自拿不同连接),或用 errgroup 在 Tx 外并行收集再统一提交。
6.9 占位符方言差异
Go
// MySQL / SQLite:? 位置占位符
"SELECT * FROM t WHERE id = ?"
// PostgreSQL:$N 编号占位符
"SELECT * FROM t WHERE id = $1"
解法:写 SQL 时先确认驱动方言;要跨库兼容就抽一层 SQL 模板层(或用 GORM/sqlx 的绑定语法)。
6.10 参数类型不匹配被隐式转换坑
Go
db.Exec("SELECT * FROM t WHERE id = ?", "123") // 字符串当 int 传
// 多数驱动能隐式转换,但可能走上全表扫描(MySQL 隐式类型转换丢索引)
解法:参数显式转类型:strconv.ParseInt 后传 int64;日期传 time.Time 而非字符串。
6.11 MySQL 时间字段 Scan 报错
Go
// DSN 未加 parseTime=true
db.Query("SELECT created_at FROM t") // Scan 到 time.Time 报错:unsupported Scan
解法:MySQL DSN 加 ?parseTime=true&loc=Local;或 Scan 到 \[\]byte 自己解析。
6.12 DECIMAL 精度丢失
float64 表示不了所有 DECIMAL(如金额 0.1+0.2),Scan 到 float64 会损失精度。 解法:DECIMAL 列 Scan 到 string 或 \[\]byte;需要计算用 math/big 或专门的 decimal 库。
6.13 忘记检查 Rows.Err
Go
for rows.Next() { ... } // 迭代中网络中断/驱动错误
// 不检查 rows.Err() → 数据不完整但看起来成功
解法:遍历结束后必查 rows.Err()(见 3.5 三步曲)。
6.14 连接池上限导致死锁(嵌套查询占满连接)
Go
db.SetMaxOpenConns(1) // 极端:只允许 1 条连接
rows, _ := db.Query("SELECT ...")
for rows.Next() {
db.Query("SELECT ...") // 连接已被 rows 占用,这里永远等不到 → 死锁
}
解法:SetMaxOpenConns 留足余量;嵌套查询改用 rows.Close() 后重查,或避免"大结果集上逐行查库"的循环模式(一次性 JOIN 取出)。
6.15 Stmt 泄漏
Prepare 后不 Close,*sql.Stmt 内部持有的连接永远不会归还,最终连接池耗尽。 解法:stmt, _ := db.Prepare(...); defer stmt.Close()(见 4.3)。
6.16 连接池参数未调优就上线
默认 maxIdle=2,高并发下频繁建连;maxOpen 无上限,尖峰流量可能瞬间建立上千连接打垮数据库。 解法:按 3.3 表格给生产参数;用 Stats() 观察 WaitCount,持续增长说明池太小。
6.17 忽略 Exec 返回值
Go
res, err := db.Exec("UPDATE ...")
// 忘检查 res.RowsAffected() == 0 → 以为更新成功,其实条件没匹配到任何行
解法:写操作后检查 res.RowsAffected()(注意 MySQL 默认返回受影响行数,SQLite 返回行数一致;驱动差异见文档)。
6.18 大结果集一次性加载内存
db.Query 返回游标后,驱动可能 全量拉取(MySQL 默认流式则需特殊配置,SQLite 全量在内存)。 解法:分页查询(LIMIT/OFFSET 或 keyset);或确认驱动是否支持流式游标;QueryRow 用于单行。
6.19 事务隔离级别误用
默认 LevelDefault(驱动默认,MySQL 是 REPEATABLE READ)。工业采集写多读少场景若要求"读到的都是已提交值",显式指定 LevelReadCommitted。 解法:按业务语义显式传 TxOptions;不确定时保持默认并理解驱动语义。
6.20 驱动并发模型误解
把驱动对象当共享变量乱用(如多个 goroutine 直接共用一个 driver.Conn 的句柄绕过 sql 层)------标准库外的操作不受保护,会帧交错。 解法:永远只通过 *sql.DB 的公开 API 访问;不要自行缓存 Rows 底层句柄。
6.21 时区与 SQLite 时间存储
SQLite 无原生 DATETIME 类型,存 time.Time 时驱动可能存成字符串/数字,排序和比较语义与 MySQL 不同。 解法:统一约定存储格式(RFC3339 字符串或 Unix 时间戳),查询时 datetime(ts, 'unixepoch') 转换;跨库迁移时先核对时间语义。
6.22 连接串串行化:一个连接被两个事务抢用
Go
tx1, _ := db.Begin()
go func() { tx1.Exec(...) }() // goroutine 并发用 tx1
go func() { tx1.Exec(...) }() // 同 tx1 → 非法
解法:Tx 不可并发(同 6.8);若必须并发写,每个 goroutine 独立 BeginTx。
7. 性能优化实践
7.1 连接池参数速查
| 场景 | MaxOpen | MaxIdle | ConnMaxLifetime | 说明 |
|---|---|---|---|---|
| 低并发工具脚本 | 默认/不设 | 默认 | 不设 | 无所谓 |
| Web API(几十并发) | 50~100 | 同 MaxOpen | 30min | 防 DB 侧断连 |
| 工业采集网关(常驻长连接) | 与点位采集并发数匹配 | 略小于 MaxOpen | 30min | 长连接 + 定时 Ping |
| 批处理/ETL | 与 DB max_connections 匹配 | 少 | 短(任务结束即关) | 用完即走 |
7.2 批量写入三种方式对比(1 万行实测量级)
| 方式 | 代码形态 | 性能 | 风险 |
|---|---|---|---|
| 循环单条 Exec | for { db.Exec(INSERT) } | 最慢(每行一次事务提交) | 无 |
| 单事务 + Stmt 复用 | tx + tx.Prepare + 循环 Exec | 快(见 4.4/4.8) | 事务内任何失败全回滚 |
| 多值批量 | INSERT INTO t VALUES(?,?),(?,?)... | 最快 | 单条 SQL 过大(MySQL 有 max_allowed_packet) |
工业采集网关建议:单事务 + Stmt 复用,采集周期内攒批(如 1000 行/批)落库;特殊高吞吐场景再考虑多值批量(注意 SQLite 参数上限 999 个占位符,批量行数 × 列数 ≤ 999)。
7.3 其他优化点
- 预处理复用:同一 SQL 模板的高频查询/写入,Prepare 一次反复 Exec(数据库侧可复用执行计划)。
- Context 贯穿:所有操作带 ctx,超时及时终止,避免慢查询长期占连接。
- Stats 监控:接入指标采集,WaitCount 增长 → 扩池;MaxLifetimeClosed 激增 → 生命周期设太长/数据库侧超时太短。
- 驱动选型:优先纯 Go(无 CGO,交叉编译方便)且实现 SessionResetter/QueryerContext 的驱动;工业网关部署在 Windows/ARM 场景时 CGO 驱动的编译负担不可忽视。
- 避免 ORM 全量反射扫描:ORM 方便但每条查询都有反射开销;高频热路径(如每秒一次的采集落库)建议手写 database/sql + Stmt 复用,ORM 留给低频管理接口。
8. FAQ 速查表
Q1:sql.Open 和 sql.OpenDB 什么区别? Open 按注册名查驱动 + 解析 DSN 字符串;OpenDB 直接接收 driver.Connector,适合需要自定义连接器(如注入 TLS 配置、自定义拨号)的场景。两者都返回并发安全的 *sql.DB。
Q2:什么时候用 Query / QueryRow / Exec?
- Query:返回多行结果,需要遍历。
- QueryRow:确定只有一行(或只取第一行),内部自动 Close。
- Exec:INSERT/UPDATE/DELETE/DDL,不返回行。
Q3:如何判断数据库真的连接成功了? PingContext(或 Ping)。Open 本身不建连。
Q4:连接池参数设多少合适? 没有通用答案。原则:MaxOpen ≤ 数据库 max_connections;ConnMaxLifetime < 数据库 wait_timeout;用 Stats() 观察 WaitCount 调优(见 7.1)。
*Q5:sql.DB 可以多个 goroutine 共用吗? 可以,这正是连接池的意义。*sql.Tx/*sql.Rows 不行。
Q6:事务里能用 db.Query 吗? 可以但不建议:db.Query 会从池里再借一条连接,事务自身的连接与查询连接不是同一个,读不到未提交的数据(除非事务已提交)。事务内统一用 tx.Query/tx.Exec。
Q7:Scan 报 converting NULL to ... is unsupported 怎么办? 用 sql.Null* 类型或自定义 sql.Scanner(见 4.6)。
Q8:MySQL 时间字段怎么 Scan 到 time.Time? DSN 加 parseTime=true;否则驱动返回 \[\]byte,Scan 到 time.Time 报错。
Q9:为什么连接池里有连接但还是报 connection reset / invalid connection? 连接被数据库侧超时回收但客户端不知情。解法:SetConnMaxLifetime 设小一点;MySQL 可在 DSN 加 timeout/readTimeout 检测半死连接。
Q10:批量插入最快的方式? 单事务 + Stmt 复用最稳;追求极致再上多值 VALUES(注意占位符上限,SQLite 999、MySQL 受 max_allowed_packet 限制)(见 7.2)。
Q11:sqlmock 怎么用? github.com/DATA-DOG/go-sqlmock 实现 driver.Driver 接口,sqlmock.New() 返回 *sql.DB + mock 控制器,可断言 SQL 文本与参数。适合单元测试不含真实数据库的代码路径。
Q12:GORM 和 database/sql 怎么选? 简单 CRUD/原型/管理界面 → GORM;高频热路径/极致性能/精确控制事务与 SQL → 原生 database/sql。二者可共存(见 4.7)。
Q13:Windows 下编译 SQLite 驱动要注意什么? mattn/go-sqlite3 需要 CGO + gcc(如 TDM-GCC/msys2),交叉编译受限;modernc.org/sqlite 纯 Go 无此问题,工业网关建议选它。
Q14:如何实现优雅关闭时等待进行中的查询结束? db.Close() 会等待 in-use 连接归还(带 Context 超时则提前终止等待中的请求);结合 shutdownCtx 传给所有进行中操作(见 4.5)。
9. 总结
database/sql 是 Go 数据库访问的事实标准地基,它的价值不在"能连数据库",而在三个设计决策:
- 接口抽象 + 驱动注册制:业务代码与具体数据库解耦,连接池/事务/预处理/扫描这些横切能力由标准库统一提供,驱动只需实现最原始的协议细节。
- 连接池的"单占用 + 借用归还"模型:让 *sql.DB 天然并发安全,驱动层可以无锁;四个池参数 + Stats() 是生产调优的全部抓手。
- Context 贯穿 + 渐进式能力协商:从 Go 1.8 起超时/取消成为一等公民;驱动通过可选接口逐步获得新能力,生态平滑演进。
对工业数采项目的落地建议(衔接本系列完整链路):
- 采集网关的配置/元数据存储用 SQLite(modernc.org/sqlite 纯 Go 驱动,Windows/ARM 免 CGO 编译);
- 点位采集高频落库用"单事务 + Stmt 复用"批量写,周期 1~5 秒一批;
- 连接池按"常驻长连接"调参并接入 Stats() 监控;
- 与 TDengine 的时序明细、GORM 的管理接口(若用)共享同一套 database/sql 心智,驱动/连接池/事务/扫描的坑一次踩平,处处复用。