Go 数据库 NULL 值入门:sql.NullString、指针和业务语义

本文讲解 Go database/sql 中 NULL 值的处理方式,包括 sql.NullString、sql.NullTime、指针字段和 JSON 响应转换。

NULL 不是空字符串,也不是 0

数据库里经常会有 NULL。用户昵称可能未设置,文章发布时间可能为空,订单取消时间可能不存在。Go 里字符串零值是 "",整数零值是 0,时间零值是 time.Time{}。这些零值和数据库 NULL 不是一回事。把 NULL 粗暴扫进普通字段,轻则报错,重则丢失业务语义。

database/sql 提供了一组 Null 类型,比如 sql.NullStringsql.NullInt64sql.NullTime。它们同时保存值和是否有效。你也可以用指针表示可空字段。怎么选,要看数据层和 API 层的语义。

这篇文章用用户资料和文章发布时间举例。

sql.NullString

表:

CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  email TEXT NOT NULL,
  nickname TEXT NULL
);

结构体:

type UserRow struct {
	ID       int64
	Email    string
	Nickname sql.NullString
}

查询:

func FindUser(ctx context.Context, db *sql.DB, id int64) (UserRow, error) {
	const query = `SELECT id, email, nickname FROM users WHERE id = ?`

	var user UserRow
	err := db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Nickname,
	)
	if err != nil {
		return UserRow{}, err
	}
	return user, nil
}

使用:

if user.Nickname.Valid {
	fmt.Println(user.Nickname.String)
} else {
	fmt.Println("nickname is not set")
}

Valid 为 false 表示数据库里是 NULL。String 字段此时不代表业务值。

转成 API 响应

不要把 sql.NullString 直接暴露给前端:

{"String":"小林","Valid":true}

这不是好的 API。定义响应结构:

type UserResponse struct {
	ID       int64   `json:"id"`
	Email    string  `json:"email"`
	Nickname *string `json:"nickname,omitempty"`
}

转换:

func ToUserResponse(row UserRow) UserResponse {
	var nickname *string
	if row.Nickname.Valid {
		value := row.Nickname.String
		nickname = &value
	}

	return UserResponse{
		ID:       row.ID,
		Email:    row.Email,
		Nickname: nickname,
	}
}

这样数据库层使用 sql.NullString,API 层使用 *string 表示可空 JSON 字段。层与层之间语义更清楚。

sql.NullTime

文章发布时间:

type ArticleRow struct {
	ID          int64
	Title       string
	PublishedAt sql.NullTime
}

判断是否已发布:

func (a ArticleRow) IsPublished(now time.Time) bool {
	return a.PublishedAt.Valid && !a.PublishedAt.Time.After(now)
}

转响应:

type ArticleResponse struct {
	ID          int64      `json:"id"`
	Title       string     `json:"title"`
	PublishedAt *time.Time `json:"published_at,omitempty"`
}

func ToArticleResponse(row ArticleRow) ArticleResponse {
	var publishedAt *time.Time
	if row.PublishedAt.Valid {
		value := row.PublishedAt.Time
		publishedAt = &value
	}
	return ArticleResponse{
		ID:          row.ID,
		Title:       row.Title,
		PublishedAt: publishedAt,
	}
}

时间零值 0001-01-01 不应该被当成“未发布”。未发布就是 NULL,应该显式表达。

指针字段能不能直接 Scan

有些人喜欢用指针:

type User struct {
	Nickname *string
}

在一些驱动和扫描场景里可以工作,但 sql.NullString 更明确,也更通用。数据访问层用 sql.Null* 类型通常更稳,业务层再转换成指针或明确状态。

关键是不要让 NULL 语义在系统里乱跑。数据层、业务层、API 层各自使用适合自己的表达,并在边界转换。

写入时也要表达清楚

读取 NULL 需要 sql.Null*,写入时也一样。比如用户清空昵称,和用户没有提交昵称字段,是两个不同动作。前者可能要把数据库字段更新成 NULL,后者应该保持原值不变。如果接口层没有区分这两件事,最后很容易出现“保存资料把昵称清掉了”这类问题。

一个简单的更新参数可以这样设计:

type UpdateUserInput struct {
	NicknameSet bool
	Nickname    *string
}

func buildNicknameValue(input UpdateUserInput) (sql.NullString, bool) {
	if !input.NicknameSet {
		return sql.NullString{}, false
	}
	if input.Nickname == nil {
		return sql.NullString{Valid: false}, true
	}
	return sql.NullString{String: *input.Nickname, Valid: true}, true
}

返回的第二个 bool 表示是否需要更新这个字段。这样“未提交”“提交 null”“提交字符串”三个状态都能表达。很多 NULL 相关 bug 不是数据库难用,而是业务层把状态压扁成了一个字符串或一个零值。只要边界模型设计清楚,SQL 反而很直接。

写测试时也要覆盖这三个状态。只测“有昵称”是不够的,还要测“昵称为 NULL”和“更新时不碰昵称”。NULL 的问题通常不会在类型编译阶段暴露,只有把边界场景写进测试,才能避免后续重构把语义改丢。

小结

数据库 NULL 值需要显式处理。sql.NullStringsql.NullInt64sql.NullTime 能区分“有值”和“没有值”。不要把 NULL 混同于空字符串、0 或时间零值。

数据层可以使用 sql.Null*,API 层通常转换成指针字段或明确响应结构。这样既保留数据库语义,也给前端更自然的 JSON。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页