登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go database/sql.Rows 怎样处理可选字段

来源:17golang原创

时间:2026-11-06 00:07:00 279浏览 收藏

用 Go 读取数据库列表时,最容易踩到的坑不是 for rows.Next(),而是某一列偶尔为 NULL。如果仍然把它直接扫描到 string、int64 或 time.Time,结果可能在少数数据上才失败。更稳妥的做法是让扫描目标同时保存“值”和“是否存在”:字符串用 sql.NullString,整数用 sql.NullInt64,时间用 sql.NullTime,自定义类型则实现 sql.Scanner。

本文围绕 database/sql.Rows 的多行读取闭环展开:先判断字段语义,再选择目标类型,最后补齐 Err、Close 和动态列的边界。官方资料入口:https://pkg.go.dev/database/sql。

先把 NULL、空字符串和空字节分开

数据库中的 NULL 表示“没有值”,而不是一个特殊的 Go 字符串。空字符串是存在一个长度为 0 的字符串,空字节切片则是存在一段长度为 0 的二进制数据。三者如果在业务层被压成同一种表现,后续的筛选、补默认值和更新操作都会失去信息。

Rows.Scan 会把当前行的每一列写入对应目标,目标数量必须与列数量一致。普通目标适合确定不会为 NULL 的列;可选字段应该使用实现了 sql.Scanner 的类型,或者先扫描到 any 再自行判断。

database/sql.Rows 的列值、NULL 状态与 Go 可空目标类型关系静态说明图
图1:数据库列值通过 Rows.Scan 进入普通值、sql.Null* 或 Scanner 目标的关系示意图,不是运行截图。

Rows 的最小读取骨架要完整

一个可复用的读取函数至少要处理四个节点:查询是否成功、每轮 Next 后的 Scan 是否成功、遍历结束后的 Err 是否为空,以及结果集是否及时关闭。Next 返回 false 既可能代表正常结束,也可能代表迭代错误,所以不能只看循环是否结束。

func loadUsers(ctx context.Context, db *sql.DB) ([]User, error) {
	// 只查询需要的列,避免 Scan 目标与结果列数量失配。
	rows, err := db.QueryContext(ctx, `
		SELECT id, nickname, score, last_seen
		FROM users
		WHERE enabled = ?`, true)
	if err != nil {
		return nil, err
	}
	defer rows.Close() // 提前返回时也释放结果集和底层连接。

	users := make([]User, 0)
	for rows.Next() {
		var (
			id       int64
			nickname sql.NullString
			score    sql.NullInt64
			lastSeen sql.NullTime
		)
		// Scan 必须紧跟在 Next 之后,目标顺序对应 SELECT 列顺序。
		if err := rows.Scan(&id, &nickname, &score, &lastSeen); err != nil {
			return nil, err
		}
		users = append(users, User{
			ID:       id,
			Nickname: nickname,
			Score:    score,
			LastSeen: lastSeen,
		})
	}
	// 循环结束后区分“没有更多行”和“迭代过程中发生错误”。
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return users, nil
}

这个骨架没有把 NULL 强行转换成业务默认值。调用方可以根据 Valid 决定展示空白、使用默认分数,还是跳过这条记录;数据库层和业务层的职责因此保持清楚。

用 sql.Null* 保存值和 Valid 状态

sql.NullString、sql.NullInt64、sql.NullFloat64、sql.NullBool 和 sql.NullTime 都遵循同一种表达:值字段保存转换后的 Go 值,Valid 表示源列是否不是 NULL。

type User struct {
	ID       int64
	Nickname sql.NullString
	Score    sql.NullInt64
	LastSeen sql.NullTime
}

func displayNickname(v sql.NullString) string {
	// Valid 为 false 时保留“没有值”的语义,而不是误判成用户输入了空字符串。
	if !v.Valid {
		return "未设置昵称"
	}
	return v.String
}

func scoreOrZero(v sql.NullInt64) int64 {
	// 只有业务明确要求默认值时,才在展示层把 NULL 映射为 0。
	if !v.Valid {
		return 0
	}
	return v.Int64
}

直接把可空字符串扫描到 *string,或者把可空整数扫描到 *int64,会把数据库驱动传来的 nil 交给不接受 NULL 的目标,通常返回转换错误。不要通过给普通变量预置默认值来“修复”它,因为 Scan 失败时这个默认值并不能代表数据库实际状态。

自定义字段用 Scanner 控制转换

当字段需要同时保存原始文本、解析后的值和合法性时,可以实现 sql.Scanner。Scanner 收到的源值可能是 nil、int64、float64、bool、[]byte、string 或 time.Time,实现者应拒绝无法无损保存的输入。

type OptionalCode struct {
	Value string
	Valid bool
}

func (o *OptionalCode) Scan(src any) error {
	// nil 是数据库 NULL;清空 Value 并保留 Valid=false。
	if src == nil {
		o.Value = ""
		o.Valid = false
		return nil
	}

	switch v := src.(type) {
	case string:
		// 驱动直接提供字符串时无需再次转换。
		o.Value, o.Valid = v, true
		return nil
	case []byte:
		// 这里立即复制为 string,避免保留驱动下一次 Scan 会复用的字节引用。
		o.Value, o.Valid = string(v), true
		return nil
	default:
		return fmt.Errorf("optional code: unsupported source %T", src)
	}
}

如果字段只是普通可空字符串,优先使用标准类型;只有当业务确实需要额外解析规则时才写自定义 Scanner。这样可以把转换规则集中在一个类型里,避免每个 Rows 循环都重复判断。

动态列不要猜类型,先保留原始值

报表、插件查询或用户自选字段可能在运行时才知道列数。这时可以先通过 Columns 得到列名,再创建与列数相同的 []any 和指针数组,把每一列交给 Scan。扫描到 *any 时,驱动返回的 []byte 会被复制给调用方,适合在下一次 Next 后继续使用。

func scanDynamicRow(rows *sql.Rows) (map[string]any, error) {
	columns, err := rows.Columns()
	if err != nil {
		return nil, err
	}

	values := make([]any, len(columns))
	addresses := make([]any, len(columns))
	for i := range values {
		// 每个地址都指向对应槽位,不能在循环外复用一个临时变量。
		addresses[i] = &values[i]
	}
	if err := rows.Scan(addresses...); err != nil {
		return nil, err
	}

	result := make(map[string]any, len(columns))
	for i, name := range columns {
		// nil 仍然表示 NULL,不能在这里无条件改成空字符串。
		result[name] = values[i]
	}
	return result, nil
}

动态列方案适合保留信息,不适合替代稳定的数据模型。需要参与排序、计算或接口输出时,仍应在边界层把 any 转换成明确类型,并记录转换失败的列名。

Rows 的查询、Next、Scan、Err、Close 与动态列边界静态结构图
图2:Rows 读取对象、列元数据、可空目标和资源关闭边界的结构示意图,不是运行截图。

ColumnTypes 只能提供辅助信息

rows.ColumnTypes() 可以返回列名、数据库类型名、长度、可空性和适合扫描的类型等信息,但官方文档明确提示部分信息依赖驱动支持。它适合帮助动态查询建立初始映射,不适合单独作为业务校验的唯一依据。

func describeColumns(rows *sql.Rows) error {
	types, err := rows.ColumnTypes()
	if err != nil {
		return err
	}
	for _, column := range types {
		nullable, ok := column.Nullable()
		// ok=false 表示驱动没有提供可靠的可空元数据。
		fmt.Printf("name=%s dbType=%s nullable=%t known=%t scanType=%v\n",
			column.Name(), column.DatabaseTypeName(), nullable, ok, column.ScanType())
	}
	return nil
}

实际扫描目标应以查询结果和业务约定为准:确认列可空就使用 sql.Null* 或 Scanner;确认列稳定且非空才使用普通值。这样即使换了驱动,也不会因为元数据缺失而把 NULL 误判为不可能出现。

提前退出、多结果集和 Close 错误怎么处理

完整遍历到最后一行后,Rows 通常会自动关闭;但函数中途因为 Scan 错误、业务过滤或上下文取消而返回时,defer rows.Close() 仍然应该保留。Close 是幂等的,重复调用不会破坏 Rows.Err 的结果。

如果驱动或查询会返回多个结果集,每次处理完一个结果集后调用 NextResultSet,进入新结果集后再次先调用 Next 再调用 Scan。所有结果集都处理完后仍要检查 rows.Err()。

常规只读查询中,延迟关闭加最终 Err 已经是清晰的默认写法。如果驱动可能在关闭阶段报告重要错误,或者执行的是会产生提交/回滚影响的语句,则可以显式调用 Close 并单独处理返回值,不要把它静默丢掉。

可选字段的选择表

场景推荐目标判断方式
字段确定非 NULL*string、*int64 等普通指针Scan 返回错误时立即处理
可空字符串、数字、时间sql.NullString、sql.NullInt64、sql.NullTime读取值字段和 Valid
需要自定义解析实现 sql.Scanner覆盖 nil 和驱动可能返回的源类型
列数运行时决定[]any 加地址数组保留 nil,再按列名做显式转换
需要字段描述ColumnTypes()把驱动元数据当辅助信息

常见问题

为什么变量已经有默认值,Scan 仍然会失败?

默认值只存在于 Go 变量里,不能改变驱动传来的 NULL。可空字段应改用 sql.Null* 或 Scanner,再由业务层决定默认展示。

Rows.Scan 能不能在 Next 之前调用?

不能。官方文档要求每次 Scan(包括第一行)之前都调用 Next;正确顺序是 Next -> Scan,循环结束后再看 Err。

sql.RawBytes 适合保存可选字段吗?

它只适合短生命周期读取。RawBytes 引用驱动拥有的内存,下一次 Next、Scan 或 Close 后可能失效;需要长期保存时应扫描到 []byte 或复制数据。

总结

处理 database/sql.Rows 的可选字段,可以记住三条规则:第一,NULL 与空字符串不是同一个值;第二,用 sql.Null*、Scanner 或 any 保留可空状态;第三,始终完成 Next、Scan、Err、Close 的资源和错误闭环。只要扫描目标跟着字段语义选择,列表查询就不会因为一条异常数据才暴露问题。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>