NULL 不是空字符串,也不是 0
数据库里经常会有 NULL。用户昵称可能未设置,文章发布时间可能为空,订单取消时间可能不存在。Go 里字符串零值是 "",整数零值是 0,时间零值是 time.Time{}。这些零值和数据库 NULL 不是一回事。把 NULL 粗暴扫进普通字段,轻则报错,重则丢失业务语义。
database/sql 提供了一组 Null 类型,比如 sql.NullString、sql.NullInt64、sql.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.NullString、sql.NullInt64、sql.NullTime 能区分“有值”和“没有值”。不要把 NULL 混同于空字符串、0 或时间零值。
数据层可以使用 sql.Null*,API 层通常转换成指针字段或明确响应结构。这样既保留数据库语义,也给前端更自然的 JSON。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。