Golang中`database/sql`详解

时常忍不住幻想:若是你我的相遇可以从头再来,是否结局就会变得美好,不一样呢?假若宿命是能够穿越的门

Posted by yishuifengxiao on 2026-11-28

database/sql 的定位与基本模型

database/sql 是 Go 标准库对 SQL 数据库的通用接口层,它本身并不实现具体数据库协议,必须配合驱动使用(如 github.com/go-sql-driver/mysql、github.com/lib/pq 等)。驱动通过匿名导入完成注册:

import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)

关键认知:sql.Open 返回的 *sql.DB 不是一个连接,而是一个连接池句柄。它不直接打开连接,连接的建立是延迟的,直到第一次实际执行查询时才会发生。因此:

  • sql.Open 通常不会返回连接错误,只验证 DSN 格式;
  • 要确认数据库真正可达,需要调用 db.Ping() 或 db.PingContext(ctx)。

连接池通过 db.SetMaxOpenConns、db.SetMaxIdleConns、db.SetConnMaxLifetime 等方法配置,*sql.DB 是长期存活、可被多个 goroutine 并发安全使用的。

Exec、Query、QueryRow:用途与返回值

三者的根本区别在于是否返回结果集以及预期返回多少行。

Exec:不返回行的语句

用于 INSERT、UPDATE、DELETE 以及 DDL 等不产生结果集的语句。返回值是 sql.Result 接口和 error:

result, err := db.Exec("UPDATE users SET age = ? WHERE id = ?", 30, 1)

sql.Result 提供两个方法:

  • LastInsertId() (int64, error):通常是自增主键的最后插入 ID;
  • RowsAffected() (int64, error):受影响的行数。

注意:这两个值的可用性依赖数据库驱动,并非所有数据库都支持 LastInsertId。不应当假设它们一定有意义。

Query:返回多行结果集

用于 SELECT 等需要取回数据的语句。返回 *sql.Rows 和 error:

rows, err := db.Query("SELECT id, name FROM users WHERE age > ?", 18)
if err != nil {
return err
}
defer rows.Close()

for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return err
}
// 使用 id、name
}
if err := rows.Err(); err != nil { // 必须检查遍历过程中的错误
return err
}

使用 Query 有几个硬性要求:必须在结束后调用 rows.Close()(defer 是常见做法),否则底层连接会被一直占用,无法归还连接池;遍历结束后必须检查 rows.Err(),因为 Next() 返回 false 时可能已经发生了错误。

QueryRow:预期至多一行

当查询条件能保证最多返回一行时(如按主键或唯一键查找),使用 QueryRow 更直接。它返回 *sql.Row,Scan 方法负责将那一行扫入变量:

var name string
err := db.QueryRow("SELECT name FROM users WHERE id = ?", 1).Scan(&name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有找到记录,这是预期的业务分支
} else {
return err
}
}

QueryRow 的一个重要特性是错误延迟:QueryRow 本身永远返回非 nil 的 *Row,真正的错误(包括 sql.ErrNoRows)要等到调用 Scan 时才返回。如果查询返回多行,Scan 只取第一行,其余行被丢弃。

QueryRow查询到多条数据时会怎么样

QueryRow 查询到多条数据时,不会返回任何错误,而是会静默地只取第一行进行扫描,并丢弃其余所有行。

这个行为是 database/sql 包的设计决定的,官方文档和源码注释都有明确的说明。

行为细节:静默丢弃

根据 Go 官方文档的描述:“QueryRow 最多检索单行数据库记录……如果查询返回多行,Scan 方法会丢弃除第一行之外的所有行”。

源码中的注释也印证了这一点:Row.Scan 的说明是,它会“扫描第一个选中的行并丢弃其余部分”(scans the first selected row and discards the rest)。

这意味着:

  • 不报错:多余的记录不会触发任何错误,你的代码不会感知到数据被丢弃。
  • 结果不确定:最终拿到的是哪一行,完全取决于数据库驱动和 SQL 语句本身返回结果的顺序,如果没有使用 ORDER BY 进行明确排序,这个“第一行”是不可预测的。

为什么这样设计?

QueryRow 的定位就是用于预期最多只返回一行的场景(例如根据主键或唯一键查询)。它的文档中写道,这个方法用于“expected to return at most one row”(预期返回至多一行)。社区的讨论也指出,这更像是一种使用者约定:“‘预期至多一行’这部分是对 QueryRow 使用者的义务,而不是对其实现的约束。如果你的查询可能返回多行,一开始就不应该使用它”。

如何避免?

如果你无法保证查询条件具有唯一性(例如 WHERE name = ?,而 name 并非唯一索引),就应该使用 Query 来获取多行结果,并自行决定如何处理。

可以显式地检查是否有多余数据,例如:

rows, err := db.Query("SELECT ... WHERE ...", args)
// ...
defer rows.Close()

if rows.Next() {
// 扫描第一行
err := rows.Scan(&field1, &field2)
// ...

// 检查是否还有更多行
if rows.Next() {
return fmt.Errorf("查询返回了多行结果,预期为单行")
}
} else {
// 处理 ErrNoRows 的情况
return sql.ErrNoRows
}
return rows.Err()

database/sql 中result.Next的用法

首先需要澄清一个关键的 API 区分:database/sql 中并不存在 Result.Next 方法。sql.Result 是一个接口,只包含 LastInsertId() 和 RowsAffected() 两个方法。你询问的 Next(),实际属于另一个类型——*sql.Rows,它是 db.Query 或 db.Queryx 返回的结果集迭代器。

Rows.Next() 是遍历多行查询结果的核心方法。

Rows.Next()` 的核心机制

Next() 的作用是将游标推进到结果集的下一行,为后续的 Scan() 读取做准备。

func (rs *Rows) Next() bool

它的返回值是 bool,语义比较特殊:

  • 返回 true:成功定位到一行数据,此时才可以调用 Scan() 读取该行。
  • 返回 false:有两种可能:① 结果集已经遍历完毕(正常结束);② 在准备下一行时发生了错误。

必须理解的“错误延迟”与 Err() 检查

这是 Next() 最容易出错的地方:Next() 返回 false 并不能区分是“正常读完”还是“出错了”。标准库的设计是将迭代过程中的错误“延迟”报告,需要通过 rows.Err() 来确认最终状态。

因此,正确的遍历模式必须包含 Err() 检查:

rows, err := db.Query("SELECT id, name FROM users")
if err != nil {
return err
}
defer rows.Close()

for rows.Next() {
// 1. 在 Next() 返回 true 之后,才能 Scan
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return err // Scan 自身的错误需要立即处理
}
// 使用数据
}

// 2. 循环结束后,必须检查 Err() 来发现 Next() 期间可能发生的错误
if err := rows.Err(); err != nil {
return err
}

如果省略 rows.Err() 检查,一个在遍历中途发生的网络中断或驱动错误会被完全忽略,程序会拿到一个不完整的结果集却浑然不觉。

与 Next() 配合的关键规则

  1. Scan() 前必须调用 Next():即使是结果集的第一行,也必须先调用 Next() 将其激活,然后才能 Scan()。
  2. 自动关闭(仅限单结果集):如果查询只返回一个结果集,当 Next() 返回 false 且没有更多结果集时,Rows 会被自动关闭,此时只需检查 Err() 即可。但对于多结果集或需要提前退出循环的场景,仍然必须显式 defer rows.Close() 来保证资源释放。
  3. NextResultSet() 的前置条件:如果你的查询或存储过程返回多个结果集,在调用 NextResultSet() 切换到下一个结果集之前,必须先通过 Next() 将当前结果集完全遍历完。如果在当前结果集还有剩余行时就调用 NextResultSet(),剩余的行会被静默跳过。

实践中的常见陷阱

一个高频错误是试图手动组合 Next() 和 Scan() 来实现类似 QueryRow 的单行读取,却忽略了多行可能性和错误处理:

// 错误的做法:如果查询返回多行,会静默忽略后续行,且不检查 Err
rows, _ := db.Query("SELECT name FROM users WHERE id = ?", 1)
defer rows.Close()
if rows.Next() {
var name string
rows.Scan(&name) // 忽略了 Scan 的错误
}
// 既没有检查 rows.Err(),也没有处理“可能返回了多行”的情况

如果预期结果至多一行,应直接使用 db.QueryRow().Scan(),它会将“无结果”(sql.ErrNoRows)和扫描错误一并返回。Rows.Next() 的定位就是明确的多行遍历,使用时应当遵循 for rows.Next() { Scan } + 检查 rows.Err() 的标准范式。

database/sql 中Scan()的用法

Scan() 是 database/sql 中将结果集列值写入 Go 变量的唯一入口。它出现在两个地方:

// *sql.Rows 上:每次 Next() 推进一行后调用
func (rs *Rows) Scan(dest ...any) error

// *sql.Row 上:QueryRow 返回的单行结果
func (r *Row) Scan(dest ...any) error

两者的语义完全一致:按位置、按指针、按数量严格匹配。下面从基本规则开始,逐类展开目标类型的用法并配可运行示例。


三条不可违反的基本规则

规则 1:参数必须是”可写入的指针”

Scan 通过指针写入值,传入非指针(如直接传 id 而不是 &id)会返回错误:

var id int64
// 错误:Scan called with non-pointer destination
err := row.Scan(id)

// 正确
err := row.Scan(&id)

规则 2:参数个数必须等于结果列数

// SQL: SELECT id, name, age FROM users  → 3 列
// 正确
row.Scan(&id, &name, &age)

// 错误:sql: expected 3 destination arguments in Scan, not 2
row.Scan(&id, &name)

规则 3:参数顺序必须与 SQL 的列顺序一一对应

这是 Scan 最脆弱的地方。查询 SELECT name, id 时,Scan(&id, &name) 会把 name 的值写进 id,如果类型能转换(如 string → int64 恰好可解析)甚至不会报错,而是静默写错数据。因此,手写 SELECT 时永远显式列出列名,并且与 Scan 参数顺序保持同步。


基础类型:直接赋值

当目标类型与列类型兼容时,Scan 执行直接赋值。

type User struct {
ID int64
Name string
Age int
Active bool
Score float64
}

rows, err := db.Query("SELECT id, name, age, active, score FROM users")
if err != nil {
return err
}
defer rows.Close()

var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Age, &u.Active, &u.Score); err != nil {
return err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return err
}

注意:int 在 32 位平台上可能溢出;如果数据库列是 BIGINT,统一用 int64 最安全。


QueryRow 上的 Scan:单行场景

QueryRow 永远返回非 nil 的 *Row,错误延迟到 Scan 时才报告。因此 QueryRow(...).Scan(...) 是唯一正确的写法。

var name string
var age int

err := db.QueryRow(
"SELECT name, age FROM users WHERE id = ?", 42,
).Scan(&name, &age)

switch {
case errors.Is(err, sql.ErrNoRows):
// 业务上的"未找到",是正常分支
fmt.Println("用户不存在")
case err != nil:
// 连接错误、类型错误等
return err
default:
fmt.Println(name, age)
}

关键点:

  • sql.ErrNoRows 是 Scan 返回的唯一”无数据”标志,必须用 errors.Is 判断(驱动可能包装错误)。
  • 永远不要忽略 Scan 的返回值去用后面的变量——ErrNoRows 时变量仍是零值。
  • QueryRow 若返回多行,只取第一行,其余静默丢弃,不报错。若无法保证唯一,改用 Query。

NULL 的三种处理方式(附对比)

数据库列可为 NULL 时,直接扫到 string / int64 会报错:

var name string
err := db.QueryRow("SELECT name FROM users WHERE id = 1").Scan(&name)
// 若 name 为 NULL: sql: Scan error on column index 0, name "name":
// converting NULL to string is unsupported

方式 A:指针类型(*string、*int64、*time.Time)

NULL → nil,非 NULL → 指向具体值的指针。判空即区分 NULL 与零值:

var bio *string
var deletedAt *time.Time

err := db.QueryRow(
"SELECT bio, deleted_at FROM users WHERE id = ?", 1,
).Scan(&bio, &deletedAt)
if err != nil {
return err
}

if bio != nil {
fmt.Println("bio:", *bio)
} else {
fmt.Println("bio 为 NULL")
}
if deletedAt != nil {
fmt.Println("删除时间:", *deletedAt)
} else {
fmt.Println("未删除")
}

推荐用于需要对外 JSON 序列化、需要区分”未设置”与”零值”的场景。

方式 B:sql.Null* 类型

标准库提供 sql.NullString、sql.NullInt64、sql.NullFloat64、sql.NullBool、sql.NullTime、sql.NullByte。每个都有 Valid bool 标志:

var name sql.NullString
var age sql.NullInt64
var born sql.NullTime

err := db.QueryRow(
"SELECT name, age, born FROM users WHERE id = ?", 1,
).Scan(&name, &age, &born)
if err != nil {
return err
}

if name.Valid {
fmt.Printf("name = %q\n", name.String)
} else {
fmt.Println("name 为 NULL")
}
if age.Valid {
fmt.Printf("age = %d\n", age.Int64)
}
if born.Valid {
fmt.Printf("born = %v\n", born.Time)
}

也可以直接作为参数传给 Exec / Query,因为它们实现了 driver.Valuer:

// Valid=false 时会写入 NULL
_, err := db.Exec("UPDATE users SET bio = ? WHERE id = ?",
sql.NullString{String: "", Valid: false}, 1)

坑:sql.NullInt64 必须对应整数列。若误扫一个 TEXT 列,不会报错,而是静默得到 Valid: false,掩盖了真实的类型不匹配问题。

方式 C:SQL 层 COALESCE 消除 NULL

若业务上 NULL 可视为默认值,直接让 SQL 返回非 NULL:

var name string
var bio string
err := db.QueryRow(
"SELECT name, COALESCE(bio, '') FROM users WHERE id = ?", 1,
).Scan(&name, &bio)

好处是结构体保持基础类型;坏处是丢失了”是否 NULL”的信息。

三种方式对比

方式 Go 类型 NULL 表达 保留”未设置”语义 JSON 友好度
指针 *T *string nil ✅ ✅(null)
sql.Null* sql.NullString Valid: false ✅ ⚠️(需自定义 MarshalJSON)
COALESCE string 默认值 ❌ ✅

*interface{}:保留驱动的原始类型

当你不知道列的运行时类型,或想保留原始值时,扫进 *interface{}:

rows, err := db.Query("SELECT id, name, score, created_at FROM users")
if err != nil {
return err
}
defer rows.Close()

cols, _ := rows.Columns()
fmt.Println("列:", cols)

for rows.Next() {
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i] // 每次循环都取 vals[i] 的地址
}
if err := rows.Scan(ptrs...); err != nil {
return err
}

// vals[i] 的静态类型是 interface{},
// 动态类型按列实际类型是: int64 / float64 / []byte / string / time.Time / bool / nil
for i, v := range vals {
switch x := v.(type) {
case nil:
fmt.Printf("%s: NULL\n", cols[i])
case []byte:
fmt.Printf("%s: %s\n", cols[i], x)
default:
fmt.Printf("%s: %v (%T)\n", cols[i], v, v)
}
}
}

两个关键注意事项:

  1. 指针切片的写法:必须为每一列准备一个独立指针。常见错误是 for i := range vals { ptrs[i] = &vals[i] } 写成 for _, v := range vals { ptrs = append(ptrs, &v) }——Go 1.22 之前循环变量复用会导致所有指针指向同一地址。
  2. 字符串列通常是 []byte:许多驱动把 VARCHAR 返回为 []byte 而不是 string,需要手动 string(x) 转换。

[]byte 与 sql.RawBytes 的区别

var blob []byte
err := db.QueryRow("SELECT avatar FROM users WHERE id = 1").Scan(&blob)
// blob 是数据的一份独立拷贝,可长期持有、修改

sql.RawBytes 是别名类型 type RawBytes []byte,它会直接引用驱动内部缓冲区,避免一次内存拷贝:

rows, _ := db.Query("SELECT data FROM t")
defer rows.Close()

for rows.Next() {
var raw sql.RawBytes
if err := rows.Scan(&raw); err != nil {
return err
}
use(raw) // ⚠️ 只能在本次循环体内使用
// 下一次 rows.Next() / Scan 后 raw 可能被覆盖
}

结论:除非确实在性能敏感处且有严谨的生命周期控制,否则一律用 []byte。


time.Time 与字符串的互转

Scan 会把数据库的日期时间列转成 time.Time;也可以扫到 *string / *[]byte,此时按 RFC3339Nano 格式化:

var t time.Time
var s string

err := db.QueryRow(
"SELECT created_at, created_at FROM users WHERE id = ?", 1,
).Scan(&t, &s)

fmt.Println(t) // 2024-03-15 10:20:30 +0800 CST
fmt.Println(s) // 2024-03-15T10:20:30+08:00

如果驱动未声明类型(如 SELECT NOW() 返回 []byte 而不是 time.Time),需要检查驱动的 parseTime 配置。MySQL 驱动:

dsn := "user:pass@tcp(localhost:3306)/db?parseTime=true"
// 开启后 DATETIME/TIMESTAMP 直接返回 time.Time

数值与字符串的隐式转换边界

Scan 允许”无损”转换,但有溢出检查:

// 数据库列类型 INT,值 300
var u8 uint8
err := db.QueryRow("SELECT 300").Scan(&u8)
// err: sql: Scan error ... converting driver.Value type int64 ("300") to a uint8: value out of range

// 值 100 可以
var u8b uint8
db.QueryRow("SELECT 100").Scan(&u8b) // u8b = 100 ✅

// 数值 → string 总是安全
var s string
db.QueryRow("SELECT 12345").Scan(&s) // s = "12345" ✅

// string → int 依赖驱动/底层 strconv 解析
var n int
db.QueryRow("SELECT '42'").Scan(&n) // n = 42 ✅
// 'abc' → 会报错

实战原则:让 Go 类型与列类型尽量对齐(BIGINT → int64、DECIMAL → string 或 float64、DATETIME → time.Time),不要依赖隐式转换。


自定义 sql.Scanner:JSON、枚举、大数

当标准类型和 sql.Null* 都不够用时,实现接口:

type Scanner interface {
Scan(src any) error
}

示例 1:JSON 列 → 结构体

type Preferences struct {
Theme string `json:"theme"`
EmailNotify bool `json:"email_notify"`
LanguageList []string `json:"languages"`
}

// 在包装类型上实现,保持 Preferences 原始结构干净
type PreferencesField Preferences

func (p *PreferencesField) Scan(src any) error {
switch v := src.(type) {
case nil:
// 数据库为 NULL,使用零值
*p = PreferencesField(Preferences{})
return nil
case []byte:
return json.Unmarshal(v, p)
case string:
return json.Unmarshal([]byte(v), p)
default:
return fmt.Errorf("Preferences.Scan: unsupported type %T", src)
}
}

// 使用
type User struct {
ID int64
Preferences PreferencesField
}

var u User
err := db.QueryRow(
"SELECT id, preferences FROM users WHERE id = ?", 1,
).Scan(&u.ID, &u.Preferences)
// 之后 u.Preferences.Theme 等可直接用

示例 2:数据库枚举字符串 → Go 枚举类型

type Status string

const (
StatusPending Status = "pending"
StatusPaid Status = "paid"
StatusCanceled Status = "canceled"
)

func (s *Status) Scan(src any) error {
var str string
switch v := src.(type) {
case nil:
*s = ""
return nil
case []byte:
str = string(v)
case string:
str = v
default:
return fmt.Errorf("Status.Scan: unsupported %T", src)
}
switch Status(str) {
case StatusPending, StatusPaid, StatusCanceled:
*s = Status(str)
return nil
default:
return fmt.Errorf("invalid status: %q", str)
}
}

// 同时在写入侧实现 Valuer,用于把它当参数传
func (s Status) Value() (driver.Value, error) {
return string(s), nil
}

注意:实现 Scanner 时,src 可能就是 nil(NULL)、[]byte、int64、float64、bool、string、time.Time 之一,必须 switch 覆盖,否则遇到 NULL 会 panic 或报错。


循环中复用变量的正确写法

一个高频错误:在 for 中复用同一个变量扫描多行,然后把它的地址存起来:

// ❌ 错误:所有用户指向同一地址,最后一行的值覆盖全部
var users []*User
for rows.Next() {
var u User
rows.Scan(&u.ID, &u.Name)
users = append(users, &u) // &u 在每次循环是同一个地址(Go 1.22 前)
}

修正方式:

  • 存值而不是指针:users = append(users, u)
  • 或者在循环内创建新变量:u := new(User); rows.Scan(&u.ID, &u.Name); users = append(users, u)
  • Go 1.22 起,for 循环变量每轮独立,&u 不再复用,但显式 u := User{} 仍是最安全的写法。

Scan 的错误必须每次检查

Scan 的返回值不是装饰品。它可能返回:

场景 返回值
列值为 NULL 且目标是基础类型 sql: converting NULL to string is unsupported
数值溢出 sql: converting ... value out of range
参数数量与列数不一致 sql: expected N destination arguments in Scan, not M
参数非指针 sql: Scan error ... destination not a pointer
QueryRow 无数据 sql.ErrNoRows
自定义 Scanner 返回的错误 原样透传
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return fmt.Errorf("scan user: %w", err)
}

完整可运行示例(综合所有要点)

package main

import (
"database/sql"
"errors"
"fmt"
"time"

_ "github.com/go-sql-driver/mysql"
)

type User struct {
ID int64
Name string
Bio *string // NULL → nil
Age sql.NullInt64 // NULL → Valid=false
CreatedAt time.Time
}

func main() {
db, err := sql.Open("mysql",
"root:pass@tcp(127.0.0.1:3306)/test?parseTime=true")
if err != nil {
panic(err)
}
defer db.Close()

// ── 1. QueryRow 单行 ──
var name string
err = db.QueryRow(
"SELECT name FROM users WHERE id = ?", 1,
).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("未找到 id=1 的用户")
} else if err != nil {
panic(err)
} else {
fmt.Println("name:", name)
}

// ── 2. Query 多行 + 混合类型 ──
rows, err := db.Query(`
SELECT id, name, bio, age, created_at
FROM users ORDER BY id LIMIT 100`)
if err != nil {
panic(err)
}
defer rows.Close()

var users []User
for rows.Next() {
var u User
if err := rows.Scan(
&u.ID, &u.Name, &u.Bio, &u.Age, &u.CreatedAt,
); err != nil {
panic(err)
}
users = append(users, u)
}
if err := rows.Err(); err != nil { // 遍历错误必须检查
panic(err)
}

for _, u := range users {
bio := "<NULL>"
if u.Bio != nil {
bio = *u.Bio
}
age := "<NULL>"
if u.Age.Valid {
age = fmt.Sprint(u.Age.Int64)
}
fmt.Printf("#%d %s bio=%s age=%s at=%s\n",
u.ID, u.Name, bio, age, u.CreatedAt.Format(time.RFC3339))
}
}
  • ✅ Scan 参数:指针、顺序、数量必须严格对齐。
  • ✅ QueryRow().Scan() 用 errors.Is(err, sql.ErrNoRows) 判断无数据。
  • ✅ NULL 三选一:*T / sql.Null* / COALESCE;结构体字段推荐 *T。
  • ✅ *interface{} 保留原始类型,但字符串列常是 []byte。
  • ✅ 大结果集 → Query + Next + Scan + rows.Err();全量加载 → Select(sqlx)。
  • ✅ 循环存指针时不要复用同一变量地址。
  • ✅ 自定义 Scanner 必须处理 src == nil。
  • ✅ 手写 SELECT 显式列名,避免 SELECT * 与 Scan 参数顺序脱节。

总结

QueryRow 遇到多行结果时的“静默丢弃”行为,是其“预期单行”语义的一部分。它假定使用者已经通过查询条件(如唯一键)保证了结果集的唯一性。如果这个前提不成立,就应该改用 Query 来显式处理可能的多行结果。

方法 是否返回行 返回值 典型场景
Exec 否 sql.Result, error INSERT / UPDATE / DELETE
Query 是(多行) *sql.Rows, error SELECT 列表
QueryRow 至多一行 *sql.Row(错误延迟到 Scan) 按唯一键查单条

NULL 的处理:sql.Null* 类型与替代方案

数据库列可以为 NULL 时,如果直接 Scan 到 string、int64 等非指针基础类型,遇到 NULL 会返回错误(驱动无法将 NULL 赋给非空类型)。

标准库的 sql.Null* 类型

database/sql 内置了四种可空类型:

  • sql.NullString
  • sql.NullInt64
  • sql.NullFloat64
  • sql.NullBool

(没有 NullInt32、NullUint64 等,标准库只实现了这四种基础可空类型)。

每个类型都包含值字段和 Valid 字段:

type NullString struct {
String string
Valid bool // Valid is true if String is not NULL
}

使用方式:

var name sql.NullString
err := db.QueryRow("SELECT name FROM users WHERE id = ?", 1).Scan(&name)
if err != nil {
return err
}
if name.Valid {
fmt.Println(name.String) // 非 NULL
} else {
// 数据库值为 NULL
}

NullInt64 对应 .Int64 / .Valid,NullFloat64 对应 .Float64 / .Valid,NullBool 对应 .Bool / .Valid。

它们同时实现了 sql.Scanner 和 driver.Valuer 接口,因此既可以作为 Scan 的目标,也可以作为查询参数传入。

其他处理 NULL 的方式

方式一:扫描到指针类型。Scan 支持 *string、*int64 等指针目标:NULL 映射为 nil,非 NULL 映射为指向对应值的指针。Go Wiki 中展示了用 *string 接收可空列的做法。缺点是使用前需要判空,且作为参数传入时指针语义需要额外注意。

方式二:用 SQL 消除 NULL。如果业务上 NULL 可以映射为默认值,可以在查询中用 COALESCE(column, '默认值'),从而始终得到非 NULL 结果,直接扫描到基础类型。

方式三:使用第三方可空类型。如 guregu/null、nullx 等库,在 sql.Null* 基础上增加了 JSON 序列化、零值转换等便利功能。

Null* 类型在结构体中的使用

type User struct {
ID int64
Name sql.NullString
Email sql.NullString
Age sql.NullInt64
}

在 Scan 时传入 &user.Name 等字段地址即可。后续使用时通过 user.Name.Valid 判断是否为 NULL。这在数据库 schema 中存在可空列时是最直接的方案,但代价是结构体字段不再是原生 string / int64,使用时需要频繁访问 .Valid 和实际值字段。

sql.Null*的用法

sql.Null* 是 database/sql 包为解决数据库 NULL 值问题而提供的标准方案。当数据库列允许为 NULL 时,将其直接扫描(Scan)到 Go 的基础类型(如 string、int64)会返回错误,因为 Go 的基础类型没有“空”的概念,其零值(如 ""、0)也无法与真实的 NULL 区分。

sql.Null* 类型家族与内部结构

所有 sql.Null* 类型都遵循相同的设计模式:包含一个存放实际值的字段和一个表示有效性的 Valid 布尔字段。标准库中主要包含以下类型:

类型 值字段 对应 Go 基础类型 说明
sql.NullString String string string 可空字符串
sql.NullInt64 Int64 int64 int64 可空 64 位整数
sql.NullFloat64 Float64 float64 float64 可空双精度浮点数
sql.NullBool Bool bool bool 可空布尔值
sql.NullTime Time time.Time time.Time 可空时间(Go 1.13+)
sql.NullByte Byte byte byte 可空字节(Go 1.17+)
sql.NullInt16 / NullInt32 Int16 / Int32 int16 / int32 可空 16/32 位整数

以 NullString 为例,其源码结构如下:

type NullString struct {
String string
Valid bool // Valid is true if String is not NULL
}

Valid 为 true 时,String 字段包含有效数据;Valid 为 false 时,表示数据库值为 NULL,此时 String 是零值,不应使用。

核心机制:Scan 与 Value

sql.Null* 类型之所以能无缝工作,是因为它们同时实现了两个关键接口:

  1. sql.Scanner:使其可以作为 Scan 的目标。当驱动遇到 NULL 时,会将 Valid 设为 false;否则填充值字段并设置 Valid = true。
  2. driver.Valuer:使其可以作为 Exec / Query 的参数。当 Valid = false 时,它会被序列化为 SQL 的 NULL 写入数据库。

基础用法:查询与写入

查询(Scan)

var age sql.NullInt64
err := db.QueryRow("SELECT age FROM users WHERE id = ?", 1).Scan(&age)
if err != nil {
return err
}

if age.Valid {
fmt.Printf("年龄: %d\n", age.Int64)
} else {
fmt.Println("年龄: NULL (未设置)")
}

写入(作为参数)

// 写入 NULL:Valid 为 false
_, err := db.Exec("UPDATE users SET age = ? WHERE id = ?",
sql.NullInt64{Valid: false}, 1)

// 写入具体值
_, err = db.Exec("UPDATE users SET age = ? WHERE id = ?",
sql.NullInt64{Int64: 30, Valid: true}, 1)

各类型示例

sql.NullString

var bio sql.NullString
db.QueryRow("SELECT bio FROM users WHERE id=?", 1).Scan(&bio)
if bio.Valid {
fmt.Println(bio.String)
}

sql.NullTime (Go 1.13+)

var deletedAt sql.NullTime
db.QueryRow("SELECT deleted_at FROM users WHERE id=?", 1).Scan(&deletedAt)
if deletedAt.Valid {
fmt.Println("删除时间:", deletedAt.Time)
} else {
fmt.Println("未删除")
}

sql.NullBool & sql.NullFloat64

用法完全一致,分别通过 .Bool 和 .Float64 访问值字段。

进阶用法

JSON 序列化问题与自定义封装

标准 sql.Null* 类型没有实现 json.Marshaler 接口,直接 json.Marshal 会输出其内部结构(如 {"String":"","Valid":false}),而不是期望的 null 或具体值。

解决方案是定义一个别名类型并为其实现 MarshalJSON 方法:

type NullStringJSON sql.NullString

func (ns NullStringJSON) MarshalJSON() ([]byte, error) {
if ns.Valid {
return json.Marshal(ns.String)
}
return []byte("null"), nil
}

// 使用
type User struct {
Bio NullStringJSON `json:"bio"`
}
// 当 Bio.Valid == false 时,JSON 输出 "bio": null

泛型 sql.Null[T] (Go 1.22+)

Go 1.22 引入了泛型版本 sql.Null[T],其字段为 V T 和 Valid bool。它适用于标准库未预定义的类型(如 sql.Null[uint64])。但它同样不支持原生 JSON 序列化,如有需要仍需封装。

与 sqlx 的兼容性

sqlx 的结构体映射完全支持 sql.Null* 类型。只要字段类型与数据库列类型匹配,StructScan 会自动调用其 Scan 方法。

常见坑点与注意事项

  1. 类型必须严格匹配:sql.NullInt64 不能用于扫描 TEXT / VARCHAR 列。这种不匹配通常不会返回错误,而是静默失败,导致 .Valid 始终保持 false,从而掩盖真实的数据类型问题。
  2. 必须使用 & 取地址:Scan 的目标必须是指针。Scan(age) 会报错,正确写法是 Scan(&age)。
  3. 不要使用零值结构体判断 NULL:sql.NullInt64{} 的 Valid 为 false,但这与“值为 0 且 Valid 为 true”的有效 0 值是两种不同的状态。判断 NULL 的唯一依据是 Valid 字段,而非值字段是否为零值。

sql.Null* vs. 指针类型(*T)

维度 sql.Null* 指针 *T
内存分配 无额外堆分配(值类型) 非 NULL 时可能需要堆分配
使用便捷性 需检查 .Valid,再访问值字段 直接 if p != nil,逻辑更简洁
JSON 序列化 需自定义封装 原生支持(nil → null)
零值语义 明确区分 NULL 与零值 nil 与指向零值的指针天然区分

选择建议:如果仅进行数据库操作且追求零额外内存分配,sql.Null* 是标准且高效的选择;如果结构体需要频繁进行 JSON 序列化或与 API 交互,指针类型(*T)通常是更便捷的方案。

查询结果到结构体的映射

database/sql 标准库本身不提供将一行结果直接扫描到结构体的通用方法。Rows.Scan / Row.Scan 只接受一组指向变量的指针。因此,结构体映射有两条主要路径。

手动逐字段 Scan(标准库原生方式)

最直接的方式:按 SELECT 的列顺序,将结构体字段的地址依次传给 Scan:

type User struct {
ID int64
Name string
Age int64
}

func getUsers(db *sql.DB) ([]User, error) {
rows, err := db.Query("SELECT id, name, age FROM users")
if err != nil {
return nil, err
}
defer rows.Close()

var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Age); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}

这种方式完全可控、没有反射开销,但列顺序与字段顺序必须严格对应,列多时容易出错,新增或调整列时需要同步修改代码。如果字段本身可空,将对应字段类型改为 sql.Null* 或指针即可。

借助反射实现通用映射

当需要“泛型式”地将任意查询结果映射到结构体时,需要借助 reflect。核心思路是:

  1. 用 reflect.New(t) 创建结构体实例的指针;
  2. 将结构体各字段的地址收集为 []interface{};
  3. 传给 rows.Scan;
  4. 扫描成功后解引用,追加到结果切片。
func StructForScan(u interface{}) []interface{} {
val := reflect.ValueOf(u).Elem()
fields := make([]interface{}, val.NumField())
for i := 0; i < val.NumField(); i++ {
fields[i] = val.Field(i).Addr().Interface()
}
return fields
}

func StructRow(template interface{}, rows *sql.Rows) ([]interface{}, error) {
t := reflect.TypeOf(template)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
var result []interface{}
for rows.Next() {
instance := reflect.New(t).Interface()
if err := rows.Scan(StructForScan(instance)...); err != nil {
return nil, err
}
result = append(result, reflect.ValueOf(instance).Elem().Interface())
}
return result, rows.Err()
}

但这种方式仍然是按列位置扫描的,依赖 SELECT 的列顺序与结构体字段顺序一致。如果要实现按列名映射到结构体字段(真正的“结构化映射”),需要额外解析 rows.Columns() 并与结构体的 db tag 或字段名做匹配,复杂度更高。

sqlx:更常用的工程化方案

实际项目中最常见的做法是使用 github.com/jmoiron/sqlx,它封装了 database/sql,提供 StructScan:

type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
Age int64 `db:"age"`
}

rows, err := db.Queryx("SELECT id, name, age FROM users")
for rows.Next() {
var u User
if err := rows.StructScan(&u); err != nil {
return err
}
// 使用 u
}

StructScan 根据 db tag(或默认字段名映射)将列匹配到结构体字段,不依赖 SELECT 的列顺序,也支持嵌入结构体。sqlx 还提供 Select 方法直接将整个结果集扫描到 []User,以及 Get 用于单行。

若使用 pgx(PostgreSQL 驱动),在 v5+ 中也有 pgx.CollectRows 可以自动完成结构体映射。

路径选择建议

  • 列少、结构稳定:手动 Scan 最清晰,无额外依赖。
  • 中等复杂度、希望减少样板代码:sqlx 的 StructScan / Select 是标准库之外最常用的折中。
  • 需要完全动态、不引入第三方库:反射方案可行,但要接受“按列位置”的约束,且需要仔细处理字段可寻址性和错误。

如果结构体中含有可空列,sqlx 同样支持将字段声明为 sql.Null* 或指针,StructScan 会正常调用对应字段的 Scan。

sqlx的用法

sqlx 是标准库 database/sql 的增强扩展,而非替代品。它保留了 sql.DB、sql.Tx 等底层接口的全部能力,专门解决原生 database/sql 中“逐列 Scan 冗长”、“占位符手动拼接繁琐”、“结果集到结构体映射样板代码多”这三大痛点。

安装与初始化

go get github.com/jmoiron/sqlx

初始化时,sqlx.Connect 会在建立连接池后立即尝试 Ping 数据库,连接失败直接返回错误;sqlx.Open 则与标准库行为一致,只做惰性连接。

import (
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)

// 推荐:启动时验证连接可用性
db, err := sqlx.Connect("mysql", "user:pass@tcp(localhost:3306)/dbname")
if err != nil {
log.Fatal(err)
}
defer db.Close()

核心查询:Get、Select 与 Queryx

sqlx 的结构体映射围绕三个核心方法展开,它们本质上是对标准库 Query / QueryRow 的“结构体化封装”。

sqlx 方法 对应标准库语义 返回结果形态
Get(&struct, query, args...) 类似 QueryRow,至多一行 单个结构体
Select(&slice, query, args...) 类似 Query,多行 结构体切片
Queryx(...) + StructScan(&struct) 手动迭代 Rows 逐个结构体

Get:单行映射

Get 内部封装了 QueryRowx().StructScan(),目标必须传入结构体指针。它适用于按主键或唯一键查询单条记录。

type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
}

var u User
err := db.Get(&u, "SELECT id, name FROM users WHERE id = ?", 1)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 未找到记录的正常业务分支
}
return err
}

Get 与多行:如果 SQL 意外返回多行,Get 的行为与 QueryRow 一致——只取第一行,静默丢弃其余行,不报错。

Select:多行映射到切片

Select 一次性将整个结果集加载到结构体切片,省去手动 rows.Next() 循环的样板代码。

var users []User
err := db.Select(&users, "SELECT id, name FROM users WHERE age > ?", 18)

使用时需要注意:

  • 目标切片:传入切片指针(&users)即可,sqlx 内部会自动追加,无需预分配。
  • 内存边界:Select 会将全量结果读入内存。如果查询可能返回大量数据,应改用 Queryx + StructScan 流式迭代。

Queryx + StructScan:流式迭代

当结果集很大或需要在迭代中提前终止时,使用 Queryx 获得 *sqlx.Rows,然后逐行调用 StructScan。

rows, err := db.Queryx("SELECT id, name FROM users WHERE active = 1")
if err != nil {
return err
}
defer rows.Close()

var users []User
for rows.Next() {
var u User
if err := rows.StructScan(&u); err != nil {
return err
}
users = append(users, u)
}
return rows.Err()

StructScan 是 sqlx 所有结构体映射的底层方法,Get 和 Select 内部最终都调用它。


结构体字段映射规则

StructScan 按列名匹配结构体字段,优先级如下:

  1. db tag 优先:字段带有非空 db tag 时,仅用 tag 值匹配列名。db:"-" 会显式跳过该字段。
  2. 自动蛇形转驼峰:没有 db tag 时,sqlx 将列名的蛇形命名转换为驼峰字段名进行匹配,例如 created_at 匹配 CreatedAt,user_id 匹配 UserID。
  3. SQL 别名覆盖:查询中的 AS 别名是最终的匹配目标。SELECT u.name AS user_name 会匹配 db:"user_name" 的字段。

显式写出 SELECT 列名是避免映射问题的最有效习惯。使用 SELECT * 时,如果表结构变更,映射可能静默失败或字段被置为零值。

嵌套结构体的映射

sqlx 对嵌套结构体(包括匿名嵌入)不会自动递归推导前缀。直接嵌入并期望自动匹配列名是行不通的。正确的方式是使用 db tag 的 prefix 语法,且嵌入结构体必须是匿名字段。

type Author struct {
ID int64 `db:"id"`
Name string `db:"name"`
}

type Book struct {
ID int64 `db:"id"`
Title string `db:"title"`
Author Author `db:"author"` // 前缀声明
}

// SQL 必须为 Author 的列加上统一前缀别名
query := `
SELECT b.id, b.title,
a.id AS author_id,
a.name AS author_name
FROM books b
JOIN authors a ON a.id = b.author_id
`
var books []Book
err := db.Select(&books, query)

如果父结构和嵌入结构存在同名列(如都叫 id),必须通过 SQL 别名或 prefix tag 加以区分,否则 StructScan 可能静默覆盖字段或报错。


命名参数:NamedExec 与 NamedQuery

sqlx 允许使用 :name 风格的占位符,由 sqlx 内部解析并替换为驱动对应的位置参数(? 或 $1)。参数来源可以是结构体或 map。

// 使用 map
_, err := db.NamedExec(
"INSERT INTO users (name, age) VALUES (:name, :age)",
map[string]interface{}{"name": "Alice", "age": 30},
)

// 使用结构体(字段名自动映射到参数名)
type User struct {
Name string `db:"name"`
Age int `db:"age"`
}
_, err = db.NamedExec(
"INSERT INTO users (name, age) VALUES (:name, :age)",
User{Name: "Bob", Age: 25},
)

NamedQuery 用于查询场景,返回 *sqlx.Rows,后续同样可以配合 StructScan 使用。

批量插入的注意点:NamedExec 对切片不能直接展开为多行 VALUES。批量插入需要借助 sqlx.In + Rebind,或为每个参数手动生成唯一前缀后拼接 SQL。


sqlx.In:切片展开 IN 查询

标准库无法直接将切片传给 WHERE id IN (?),sqlx.In 专门解决这个问题。它只处理第一个 ?,将其展开为与切片长度相同的占位符列表,并返回新的 SQL 和参数切片。

ids := []int{1, 2, 3}
query, args, err := sqlx.In("SELECT * FROM users WHERE id IN (?)", ids)
if err != nil {
return err
}

// 关键:适配不同数据库的占位符风格
// MySQL 用 ?,PostgreSQL 用 $1
query = db.Rebind(query)

var users []User
err = db.Select(&users, query, args...)

空切片陷阱:sqlx.In 对空切片会生成 IN (),MySQL 会拒绝执行。需要提前判断 len(ids) == 0 并直接返回空结果。


事务:必须使用 Beginx

不要通过连续执行 BEGIN; ... COMMIT; 的 Exec 来管理事务,连接池的存在意味着后续语句可能运行在不同的物理连接上,事务会失效。sqlx 的事务必须从 db.Beginx() 开始,返回的 *sqlx.Tx 具备 Get、Select、NamedExec 等全部扩展方法。

tx, err := db.Beginx()
if err != nil {
return err
}
defer tx.Rollback() // 确保异常时回滚

_, err = tx.NamedExec("INSERT INTO users (name, age) VALUES (:name, :age)", user)
if err != nil {
return err
}

return tx.Commit()

sqlx.Tx 内嵌了 sql.Tx,标准库的事务方法(Exec、Query 等)依然可用,但只有使用 tx.Get、tx.Select 等 sqlx 扩展方法才能享受结构体映射。


NULL 值的处理

sqlx 本身不改变标准库对 NULL 的严格语义:NULL 扫入非指针基础类型会报错。在结构体映射中,处理可空列的方式与 database/sql 一致:

1. 指针字段(首选):NULL 映射为 nil,非 NULL 映射为指向具体值的指针。JSON 序列化友好(nil → null)。

type User struct {
ID int64 `db:"id"`
Bio *string `db:"bio"`
DeletedAt *time.Time `db:"deleted_at"`
}
// 判断:if user.Bio != nil { ... }

2. sql.Null* 类型:通过 .Valid 字段区分 NULL 与零值。注意类型必须匹配——sql.NullInt64 误扫 TEXT 列不会报错,而是静默得到 Valid: false,掩盖了真实问题。

3. SQL 层 COALESCE:如果业务上 NULL 可视为默认值,直接在查询中处理,让结果始终非 NULL,从而继续使用基础类型字段。


查询结果的列数多于结构体字段

当查询结果的列数多于结构体字段时,sqlx 的默认行为是报错 missing destination name ...,以确保映射的完整性。要解决这个问题,核心思路是打破“严格匹配”的约定。以下是几种方案,按推荐程度从高到低排列。

方案一:显式指定列名(最佳实践)

这是最根本、最安全的解决方式。放弃使用 SELECT *,在 SQL 中明确列出你希望映射到结构体的列名。

type GoodsPO struct {
Id string `db:"id"`
GoodsName string `db:"goods_name"`
}

// 只查询结构体需要的列,即使表里还有 goods_size 等其他列
query := `SELECT id, goods_name FROM goods WHERE ...`
err := db.Select(&goodsList, query)

这样做有两大好处:① 表结构新增列时,现有代码不会崩溃,天然兼容;② 查询意图清晰,避免了不必要的大字段传输。

方案二:使用 db.Unsafe() 忽略多余列

如果因某些原因必须使用 SELECT *,sqlx 提供了 Unsafe() 方法来显式关闭“严格匹配”检查。调用 Unsafe() 后,它会返回一个副本,这个副本在扫描时会静默忽略那些在目标结构体中找不到对应字段的列。

type MyTable struct {
Name string `db:"name"`
Field1 string `db:"field1"`
// 故意不包含数据库中的其他列
}

// 关键:调用 Unsafe() 获取一个“宽松”的句柄
db := db.Unsafe()

rows, err := db.Queryx("SELECT * FROM mytable")
// ... 后续的 StructScan 不会再因多余列而报错
for rows.Next() {
var myTable MyTable
if err := rows.StructScan(&myTable); err != nil {
// 此时多余列会被忽略
}
}

Unsafe() 返回的 sqlx.DB、sqlx.Tx 等句柄都会继承这种“宽松”行为。

注意:Unsafe() 是把双刃剑。它牺牲了映射的安全性,代价是可能掩盖真实的映射错误(例如,你本意是想映射 user_name,却因拼写错误写成了 UserName,在 Unsafe 模式下这种错误会被静默吞掉)。因此,仅在你能完全掌控 SQL 与结构体对应关系时使用。

方案三:改用 Query + 手动 Scan(完全控制)

如果只希望部分字段,又不愿修改 SQL 或使用 Unsafe,可以退回到更底层的 Query + Rows.Scan() 方式。这样你可以精确控制要扫描哪些列,忽略哪些列。

rows, err := db.Queryx("SELECT * FROM mytable")
for rows.Next() {
var name, field1 string
// 假设我们知道结果集有 name, field1, other_col 三列
// 我们可以只 Scan 前两列,或者按需读取
// 但更常见的是用 sql.RawBytes 或 interface{} 接收多余列再丢弃
}

不过这种方式样板代码多,且需要提前知道列的顺序,工程上通常不如前两种方案简洁。


补充:db:"-" 的适用场景

db:"-" 标签的作用是让某个结构体字段不参与 SQL 映射(无论是生成 SQL 还是扫描结果)。它适用于结构体中有一些仅用于内部逻辑、不对应任何数据库列的字段(如计算字段、临时状态),而不是用来处理“数据库列多于结构体字段”的情况。因此,它不能替代上述方案来解决你的问题。


总结

方案 核心操作 安全性 推荐场景
显式列名 SELECT col1, col2 ✅ 最高 首选,长期可维护
db.Unsafe() db.Unsafe().Select(...) ⚠️ 较低 必须用 SELECT * 的遗留代码或快速原型
手动 Scan Query + Rows.Scan ✅ 可控 极少数需要完全自定义扫描逻辑的场景

显式列出列名是社区一致推荐的做法,它从根本上避免了因表结构演进而导致的运行时错误。db.Unsafe() 则是在特定约束下的一种务实妥协。

常见坑点速查

  1. Get/Select 目标必须是结构体/切片指针:传值或传非指针会导致写入失败或 panic。
  2. Select 的内存边界:查询无上限时,全量加载可能耗尽内存,此时应换用 Queryx + StructScan 流式处理。
  3. StructScan 的列数要求:查询列数与结构体可映射字段数必须一致,SELECT * 在表结构变更时尤其容易触发 missing destination name 错误。
  4. sql.Null* 类型不匹配会静默失败:列类型与 Null 类型不兼容时,不会报错,而是得到 .Valid == false 的零值,容易掩盖数据问题。
  5. QueryRow 仍应使用 sqlx 版本:直接调用 db.QueryRow(标准库方法)无法使用 StructScan,必须使用 db.QueryRowx 或 db.Get。

日期时间数据的使用

在 database/sql 与 sqlx 中处理日期时间,核心矛盾在于:Go 的 time.Time 是一个自带时区(Location)的绝对时间点,而 MySQL 的 DATETIME 本身不含时区信息,TIMESTAMP 虽然底层存 UTC,但展示依赖会话时区。理解这一点,是理解后续所有行为的前提。


基石:驱动层配置 parseTime 与 loc

标准库 database/sql 本身不解析时间,具体行为由底层驱动决定。以最常用的 go-sql-driver/mysql 为例,有两个 DSN 参数是时间处理的基石:

  • parseTime=true:默认情况下,MySQL 驱动将 DATE/DATETIME 作为 []byte 返回。开启此参数后,驱动会尝试将其解析为 time.Time。
  • loc=...:指定解析时使用的时区。loc=Local 使用操作系统本地时区,也可以显式指定 loc=Asia%2FShanghai 等。不设置时,默认使用 UTC。
// 推荐的 DSN 配置:解析时间 + 显式时区
dsn := "user:pass@tcp(127.0.0.1:3306)/db?parseTime=true&loc=Local"
// 或
dsn := "user:pass@tcp(127.0.0.1:3306)/db?parseTime=true&loc=Asia%2FShanghai"

loc 的核心作用是声明“数据库中的裸时间值应当被解释为哪个时区的时刻”,它同时影响查询参数的发送和结果数据的解析。


结果数据:从数据库到 Go

扫描到 time.Time(需要 parseTime=true)

配置 parseTime=true 后,将 DATE、DATETIME、TIMESTAMP 列扫描到 *time.Time 是标准做法。

var createdAt time.Time
err := db.QueryRow("SELECT created_at FROM events WHERE id = ?", 1).Scan(&createdAt)

TIMESTAMP 与 DATETIME 的关键差异:

  • TIMESTAMP:驱动会依据 loc 参数进行正确的时区转换。例如,数据库存储的是 UTC 时间,loc=Asia/Shanghai 时,扫描出的 time.Time 会携带 +0800 的时区信息。
  • DATETIME:这是一个常见的陷阱。部分驱动实现下,即使配置了 loc,DATETIME 也可能被强制按 UTC 解析,而不遵循 loc 设置。这意味着一个本意是“北京时间 12:00”的 DATETIME 值,可能被解析为 12:00 UTC,导致 8 小时的逻辑偏移。因此,对于 DATETIME 字段,扫描后可能需要手动进行时区修正(如调用 .In(loc))。

扫描到字符串或字节切片(无需 parseTime)

如果未开启 parseTime,或者你希望自行控制格式,可以将时间列扫描到 *string 或 *[]byte。标准库文档指出,时间类型的源值扫描到 *string 或 *[]byte 时,会使用 RFC3339Nano 格式进行转换。

var timeStr string
err := db.QueryRow("SELECT created_at FROM events WHERE id = ?", 1).Scan(&timeStr)
// timeStr 形如 "2024-05-01T12:00:00+08:00"

NULL 时间:sql.NullTime 与 *time.Time

当时间列允许为 NULL 时,直接扫描到 time.Time 会报错(unsupported Scan, storing driver.Value type <nil> into type *time.Time)。有两种处理方式,与处理其他 NULL 类型的思路一致:

方式一:sql.NullTime(Go 1.13+)

var updatedAt sql.NullTime
err := db.QueryRow("SELECT updated_at FROM events WHERE id = ?", 1).Scan(&updatedAt)
if updatedAt.Valid {
fmt.Println("更新时间:", updatedAt.Time)
} else {
fmt.Println("尚未更新 (NULL)")
}

sql.NullTime 实现了 Scanner 和 Valuer,因此也可以作为查询参数写入 NULL。

方式二:*time.Time(指针)

var updatedAt *time.Time
err := db.QueryRow("SELECT updated_at FROM events WHERE id = ?", 1).Scan(&updatedAt)
if updatedAt != nil {
// 非 NULL
} else {
// NULL
}

指针方式在 JSON 序列化时更自然(nil → null),而 sql.NullTime 需要额外封装才能正确序列化。


查询参数:从 Go 到数据库

传递 time.Time

将 time.Time 作为查询参数传递给 Exec、Query 或 QueryRow 时,驱动会将其序列化为数据库可接受的格式(通常是 'YYYY-MM-DD HH:MM:SS')。

now := time.Now()
_, err := db.Exec("INSERT INTO events (name, created_at) VALUES (?, ?)", "test", now)

时区转换行为:驱动在序列化 time.Time 参数时,会使用 DSN 中的 loc 时区进行转换。例如,loc=Asia/Shanghai 时,一个 UTC 的 time.Time 在写入 DATETIME 或 TIMESTAMP 列前,会被驱动转换为上海时间。

传递 NULL

  • 使用 sql.NullTime{Valid: false}:驱动会将其解释为 SQL NULL 写入。
  • 使用 (*time.Time)(nil):一个值为 nil 的 *time.Time 同样会被写为 NULL。
// 写入 NULL
_, err := db.Exec("UPDATE events SET updated_at = ? WHERE id = ?",
sql.NullTime{Valid: false}, 1)
// 或
var nullTime *time.Time
_, err = db.Exec("UPDATE events SET updated_at = ? WHERE id = ?", nullTime, 1)

sqlx 中的行为

sqlx 是 database/sql 的包装,时间处理的核心逻辑完全继承自底层驱动,上述所有关于 parseTime、loc、DATETIME 陷阱的规则同样适用。sqlx 的贡献在于语法层面的简化。

StructScan / Select / Get

StructScan 底层调用的仍然是 Rows.Scan,只要驱动层配置正确,它会自动处理 time.Time、*time.Time、sql.NullTime 等类型的映射。

type Event struct {
ID int64
CreatedAt time.Time `db:"created_at"`
UpdatedAt *time.Time `db:"updated_at"` // NULL → nil
}

var events []Event
err := db.Select(&events, "SELECT id, created_at, updated_at FROM events")

StructScan 本身不解析时间字符串,它依赖驱动返回的值。如果驱动因为未开启 parseTime 而返回 []byte,StructScan 扫描到 time.Time 字段时会报错。

NamedExec 与时间参数

NamedExec 将命名参数替换为位置参数后,同样交由底层驱动执行,time.Time 参数的序列化规则与 database/sql 一致。

_, err := db.NamedExec(
"INSERT INTO events (name, created_at) VALUES (:name, :created_at)",
map[string]interface{}{"name": "test", "created_at": time.Now()},
)

需要注意的一个历史问题是:sqlx 的命名参数解析器可能将 SQL 字符串中时间值里的冒号(如 '12:00:00')误认为命名参数。如果需要在 SQL 字面量中写时间,可以用双冒号转义(::),但这通常不是推荐做法,更安全的做法始终是通过参数传递时间值。


实践要点与常见陷阱

1. 始终配置 parseTime=true,并显式设置 loc
不配置 loc 时默认使用 UTC,在跨时区服务中极易导致时间偏移。loc=Local 是面向单一时区应用的简单选择,微服务场景更推荐统一使用 UTC 存储,在展示层再做转换。

2. 警惕 DATETIME 的时区陷阱
TIMESTAMP 在驱动层有明确的时区语义,而 DATETIME 可能被驱动忽略 loc 强制解析为 UTC。如果表中有 DATETIME 列,扫描到 time.Time 后建议验证其时区是否符合预期,必要时手动 .In(loc) 修正。

3. 可空时间列不要用 time.Time
直接扫描到 time.Time 遇到 NULL 会失败。根据后续用途选择 *time.Time(JSON 友好)或 sql.NullTime(语义显式)。

4. sqlx 不改变驱动的根本行为
StructScan 的便利性依赖于驱动已经返回了 time.Time。如果 parseTime 未开启,sqlx 同样会报错,不存在绕过驱动的“sqlx 内置解析”。

可空时间列(NULL 时间)在参数与结构体中的处理

可空时间列指数据库列类型为 DATETIME / TIMESTAMP / DATE 且允许 NULL。Go 中表达”可能不存在的时间”有三种类型:*time.Time、sql.NullTime、sql.Null[time.Time](Go 1.22+ 泛型)。它们在结果扫描、作为参数、JSON 序列化、比较判断四个维度的行为各不相同。下面逐一展开,并覆盖 database/sql 与 sqlx 两种写法。


三种类型的基本形态

// 1. 指针
var t1 *time.Time
// nil 表示 NULL;非 nil 表示有效时间

// 2. sql.NullTime (Go 1.13+)
var t2 sql.NullTime
// 结构: { Time time.Time; Valid bool }
// Valid=false 表示 NULL

// 3. 泛型 sql.Null[T] (Go 1.22+)
var t3 sql.Null[time.Time]
// 结构: { V time.Time; Valid bool }
// Valid=false 表示 NULL

三者都实现了 sql.Scanner 和 driver.Valuer,因此既能作为扫描目标,也能作为查询参数。区别主要在使用便捷性和JSON 行为。


作为结果数据(扫描)

database/sql 手动 Scan

type Event struct {
ID int64
Name string
DeletedAt *time.Time // NULL → nil
UpdatedAt sql.NullTime // NULL → Valid=false
}

rows, err := db.Query("SELECT id, name, deleted_at, updated_at FROM events")
if err != nil {
return err
}
defer rows.Close()

var events []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.Name, &e.DeletedAt, &e.UpdatedAt); err != nil {
return err
}
events = append(events, e)
}

关键点:目标必须是对应类型的地址。&e.DeletedAt 是 **time.Time,&e.UpdatedAt 是 *sql.NullTime,Scan 都能正确识别。

如果误用 &e.DeletedAt 对应 time.Time 类型(即字段声明为 time.Time 而非指针),遇到 NULL 会直接报错:

var createdAt time.Time
err := db.QueryRow("SELECT created_at FROM events WHERE id = 1").Scan(&createdAt)
// 若 created_at 为 NULL:
// sql: Scan error on column index 0: unsupported Scan,
// storing driver.Value type <nil> into type *time.Time

sqlx 的 StructScan / Get / Select

sqlx 完全支持上述三种类型的字段,无需任何额外配置:

type Event struct {
ID int64 `db:"id"`
Name string `db:"name"`
DeletedAt *time.Time `db:"deleted_at"`
UpdatedAt sql.NullTime `db:"updated_at"`
SyncedAt sql.Null[time.Time] `db:"synced_at"` // Go 1.22+
}

var events []Event
err := db.Select(&events, "SELECT id, name, deleted_at, updated_at, synced_at FROM events")

sqlx 内部仍是逐列调用 Rows.Scan,所以类型选择规则与原生 database/sql 完全一致。

一个常见混淆点:如果字段声明为 time.Time(非可空类型)而数据库返回 NULL,sqlx 会报 converting NULL to time.Time is unsupported。sqlx 不会自动把 NULL 转成零时间,这是与 database/sql 一致的严格语义。


作为查询参数

可空时间作为参数时的核心问题是:如何写入 NULL,如何写入具体时间。

写入 NULL

三种方式等价:

// 方式 1: 指针 nil
var deletedAt *time.Time // 默认 nil
_, err := db.Exec("UPDATE events SET deleted_at = ? WHERE id = ?", deletedAt, 1)

// 方式 2: sql.NullTime{Valid: false}
_, err = db.Exec("UPDATE events SET deleted_at = ? WHERE id = ?",
sql.NullTime{Valid: false}, 1)

// 方式 3: 泛型 sql.Null[time.Time]{Valid: false}
_, err = db.Exec("UPDATE events SET deleted_at = ? WHERE id = ?",
sql.Null[time.Time]{Valid: false}, 1)

三种方式底层都会通过 driver.Valuer 返回 nil,被驱动序列化为 SQL NULL。

写入具体时间

now := time.Now()

// 指针
_, err := db.Exec("UPDATE events SET deleted_at = ? WHERE id = ?", &now, 1)

// sql.NullTime
_, err = db.Exec("UPDATE events SET deleted_at = ? WHERE id = ?",
sql.NullTime{Time: now, Valid: true}, 1)

// 直接传 time.Time(非可空场景)
_, err = db.Exec("UPDATE events SET created_at = ? WHERE id = ?", now, 1)

驱动层的时区行为:参数 time.Time 被序列化时,驱动会使用 DSN 的 loc 时区进行转换。例如 loc=Asia/Shanghai,一个 UTC 的 time.Time 在写入前会被转为上海时间 '2024-05-01 20:00:00' 存入数据库。

sqlx 命名参数

type UpdateParams struct {
ID int64 `db:"id"`
DeletedAt *time.Time `db:"deleted_at"`
}

// 写入 NULL
_, err := db.NamedExec(
"UPDATE events SET deleted_at = :deleted_at WHERE id = :id",
UpdateParams{ID: 1, DeletedAt: nil},
)

// 写入具体时间
t := time.Now()
_, err = db.NamedExec(
"UPDATE events SET deleted_at = :deleted_at WHERE id = :id",
UpdateParams{ID: 1, DeletedAt: &t},
)

sqlx.NamedExec 只是把 :name 替换成 ?,参数仍交给驱动,时区行为与 database/sql 相同。


JSON 序列化对比(结构性差异)

这个维度决定了很多工程中的类型选择。

*time.Time

type User struct {
DeletedAt *time.Time `json:"deleted_at"`
}

u := User{DeletedAt: nil}
b, _ := json.Marshal(u)
// {"deleted_at":null}

now := time.Now()
u.DeletedAt = &now
b, _ = json.Marshal(u)
// {"deleted_at":"2024-05-01T20:00:00+08:00"}

✅ 原生正确:NULL → null,有效值 → RFC3339 字符串。

sql.NullTime(未封装)

type User struct {
DeletedAt sql.NullTime `json:"deleted_at"`
}

u := User{DeletedAt: sql.NullTime{Valid: false}}
b, _ := json.Marshal(u)
// {"deleted_at":{"Time":"0001-01-01T00:00:00Z","Valid":false}}

不符合预期:暴露了内部结构。需要自定义封装才能得到 null 或时间字符串。

封装 sql.NullTime 使其 JSON 正确

type NullableTime struct {
sql.NullTime
}

func (n NullableTime) MarshalJSON() ([]byte, error) {
if !n.Valid {
return []byte("null"), nil
}
return json.Marshal(n.Time)
}

func (n *NullableTime) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
n.Valid = false
return nil
}
var t time.Time
if err := json.Unmarshal(data, &t); err != nil {
return err
}
n.Time = t
n.Valid = true
return nil
}

sql.Null[time.Time](Go 1.22+)

泛型版本同样没有实现 json.Marshaler,行为与 sql.NullTime 一致,序列化时会暴露 {V, Valid} 结构,需要类似封装。

结论:如果结构体会被 JSON 序列化(API 响应、消息队列等),*time.Time 是最省心的选择。sql.NullTime 适合纯数据库层使用,封装后也可用于 JSON。


比较与零值判断的坑

这是 sql.NullTime 和 *time.Time 在使用中最易混淆的地方。

判断是否 NULL

// *time.Time
if u.DeletedAt != nil { /* 非 NULL */ }

// sql.NullTime
if u.DeletedAt.Valid { /* 非 NULL */ }

两者都正确,但绝对不能用零值判断:

// ❌ 错误:零时间不代表 NULL
if !u.DeletedAt.Time.IsZero() { /* 不能用于判断 NULL */ }

因为 sql.NullTime{Time: time.Time{}, Valid: true} 是”值为零时间,且有效”的合法状态,与”NULL”完全不同。

时间比较

// *time.Time:需要先判空,否则 nil 解引用 panic
if u.DeletedAt != nil && u.DeletedAt.Before(time.Now()) { ... }

// sql.NullTime:也要先判 Valid,否则比较的是零时间
if u.DeletedAt.Valid && u.DeletedAt.Time.Before(time.Now()) { ... }

提取为 time.Time(默认值回退)

// *time.Time
var t time.Time
if u.DeletedAt != nil {
t = *u.DeletedAt
}

// sql.NullTime
var t time.Time
if u.DeletedAt.Valid {
t = u.DeletedAt.Time
}

完整示例:一个表的 CRUD

表结构与模型

CREATE TABLE tasks (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
due_date DATETIME NULL, -- 截止时间(可空)
completed_at DATETIME NULL -- 完成时间(可空)
);
type Task struct {
ID int64 `db:"id" json:"id"`
Title string `db:"title" json:"title"`
DueDate *time.Time `db:"due_date" json:"due_date"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at"`
}

选择 *time.Time 而不是 sql.NullTime,因为这个结构体同时用于 API 的 JSON 输入输出。

插入:带 NULL 和具体时间

// 插入一条未完成、有截止时间、无完成时间的任务
due := time.Date(2024, 12, 31, 23, 59, 59, 0, time.Local)

_, err := db.NamedExec(
`INSERT INTO tasks (title, due_date, completed_at)
VALUES (:title, :due_date, :completed_at)`,
Task{
Title: "年终报告",
DueDate: &due, // 具体时间
CompletedAt: nil, // NULL
},
)

查询:正确扫描 NULL 和值

var tasks []Task
err := db.Select(&tasks,
"SELECT id, title, due_date, completed_at FROM tasks ORDER BY id")

for _, t := range tasks {
fmt.Printf("ID=%d Title=%s\n", t.ID, t.Title)

if t.DueDate != nil {
fmt.Printf(" 截止: %s\n", t.DueDate.Format("2006-01-02 15:04"))
} else {
fmt.Println(" 截止: 未设置")
}

if t.CompletedAt != nil {
fmt.Printf(" 完成于: %s\n", t.CompletedAt.Format("2006-01-02 15:04"))
} else {
fmt.Println(" 完成于: 未完成")
}
}

更新:条件性写入 NULL

// 将已完成的任务的 completed_at 清空(置为 NULL),并将 due_date 更新为 nil
func ClearCompleted(id int64) error {
_, err := db.NamedExec(
`UPDATE tasks
SET completed_at = :completed_at,
due_date = :due_date
WHERE id = :id`,
Task{ID: id, CompletedAt: nil, DueDate: nil},
)
return err
}

Task{CompletedAt: nil} 中的 nil 会经 driver.Valuer 被驱动写成 NULL。

查询:按可空时间范围过滤

这是一个容易出错的场景。想找”截止时间在明天之前的任务(包含未设置截止时间的?不包含?)”:

tomorrow := time.Now().Add(24 * time.Hour)

// 只包含"有截止时间且早于明天"的任务
err := db.Select(&tasks,
"SELECT id, title, due_date FROM tasks WHERE due_date IS NOT NULL AND due_date < ?",
tomorrow)

// 包含"没有截止时间"的任务
err = db.Select(&tasks,
"SELECT id, title, due_date FROM tasks WHERE due_date IS NULL OR due_date < ?",
tomorrow)

SQL 层的 IS NULL 判断无法用 Go 端的 *time.Time 或 sql.NullTime 替代。不要在 Go 侧拉全量再过滤,应当在 SQL 层显式表达可空语义。


sqlx 使用中的具体注意事项

db:"-" 不参与映射

如果结构体有”只在内存中”的时间字段(如计算属性),用 db:"-" 排除:

type Task struct {
ID int64 `db:"id"`
DueDate *time.Time `db:"due_date"`
ComputedETA *time.Time `db:"-"` // 不从数据库读取
}

嵌套结构体中的可空时间

sqlx 不会为嵌套结构体自动推导前缀,需要显式 prefix:

type Audit struct {
CreatedAt *time.Time `db:"created_at"`
UpdatedAt *time.Time `db:"updated_at"`
}

type Order struct {
ID int64 `db:"id"`
Audit Audit `db:"audit"`
}

// SQL 必须用别名加前缀
query := `SELECT o.id,
o.created_at AS audit_created_at,
o.updated_at AS audit_updated_at
FROM orders o`

NamedExec 遇到时间字符串字面量

sqlx 的命名参数解析器会把 SQL 字符串中未转义的 :xxx 当作参数。如果 SQL 中直接写时间字面量:

// ⚠️ 有风险::00 可能被误解析
db.NamedExec("UPDATE t SET x = :x WHERE created_at > '2024-01-01 10:00:00'", params)

解决:所有时间都通过参数传入,不要写时间字面量。

cutoff := time.Date(2024, 1, 1, 10, 0, 0, 0, time.Local)
db.NamedExec("UPDATE t SET x = :x WHERE created_at > :cutoff",
map[string]interface{}{"x": 1, "cutoff": cutoff})

类型选择速查

使用场景 推荐类型 理由
结构体要 JSON 序列化 *time.Time 原生支持 null,无额外代码
纯数据库层 DTO sql.NullTime 值语义,无堆分配,语义显式
用 Go 1.22+ 且偏好泛型 sql.Null[time.Time] 与 Null[T] 家族统一风格
sqlx 的 StructScan 三者均可 sqlx 不会改变底层扫描规则
作为查询参数写 NULL 三者均可 都实现 Valuer,都输出 nil
写非 NULL 时间 三者均可 都序列化对应的时间值
SQL 中按 NULL 过滤 必须用 SQL 的 IS NULL / IS NOT NULL Go 类型无法替代 SQL 层判断

三个高频陷阱

1. 把 time.Time 字段当作”可空”

type Task struct { DueDate time.Time } // ❌ 数据库 NULL 时会报错

正确做法是 *time.Time 或 sql.NullTime。

2. 用 .IsZero() 判断 NULL

if !t.DueDate.Time.IsZero() { ... } // ❌ 零时间且 Valid=true 是合法状态

必须用 t.DueDate.Valid 或 t.DueDate != nil。

3. 忘记 parseTime=true
如果 DSN 未开启,DATETIME 列会以 []byte 返回,扫描到 *time.Time、sql.NullTime 都会报 converting []uint8 to time.Time is unsupported。这是配置遗漏,不是类型选择问题。

几个容易踩坑的点

  1. Query 后忘记 rows.Close():会导致连接池连接泄漏,SetMaxOpenConns 耗尽后所有后续查询阻塞。
  2. QueryRow 不检查 ErrNoRows:Scan 返回 sql.ErrNoRows 时意味着没有匹配行,这是正常业务分支,不应一概当作系统错误。
  3. Exec 的 LastInsertId / RowsAffected 不检查 error:这两个方法本身也可能返回错误,尤其在驱动不支持时。
  4. 用 Query 执行不需要结果集的语句:INSERT/UPDATE 应使用 Exec,用 Query 会无谓地持有连接并产生 Rows 资源。
  5. 结构体字段不可寻址:反射映射时,字段必须可寻址(通过指针获取),且字段首字母必须大写才能被 reflect 和 sqlx 访问。