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

Go database/sql 单行查询的 QueryRow 使用方式

来源:17golang原创

时间:2026-10-15 08:22:55 302浏览 收藏

在用户详情、配置读取、按主键查找这类场景里,SQL 通常只期待一条记录。Go 的 database/sql 可以用 QueryRowContext 表达这个意图:查询入口返回一个 *sql.Row,真正的查询错误和“没有记录”会在 Scan 时交付。

最容易写错的地方有三个:把 QueryRow 的返回值误当成查询错误、没有单独处理 sql.ErrNoRows,以及因为照搬 Query 的写法而寻找不存在的 Rows.Close。下面用一个按编号查用户的例子把这三个边界拆开。

官方资料:https://go.dev/doc/tutorial/database-access

先判断 QueryRow 是否适合当前查询

QueryRow 适合“最多需要一条记录”的查询,例如按唯一键取用户、按配置名取一项设置。它不会像 Query 那样返回可遍历的 *sql.Rows;如果结果有多行,Scan 只读取第一行,其余结果由 database/sql 处理掉。

相反,如果业务需要遍历订单、统计多条日志或判断多行结果,就应该使用 QueryContext,配合 defer rows.Close() 和 rows.Err()。不要因为查询语句写的是 SELECT,就默认所有读取都用同一种 API。

需求入口重点错误边界
最多一条记录QueryRowContextScan 读取错误,单独处理 sql.ErrNoRows
需要遍历多条记录QueryContextrows.Close()、rows.Scan() 和 rows.Err()
反复执行同一 SQLPrepareContext + Stmt.QueryRowContext语句创建失败与语句关闭错误

QueryRow 为什么把错误留到 Scan

QueryRowContext 的签名返回的是 *sql.Row,没有第二个 error。这不是它忽略了错误,而是把错误延迟到 Row.Scan:这样调用方可以把“查到一行并转换字段”“没有行”“字段无法转换”放在同一处判断。

业务查询函数、QueryRowContext、SQL 参数、sql.Row、Scan 和 sql.ErrNoRows 的静态关系说明图
图1:QueryRowContext、sql.Row 与 Scan 的静态关系示意图,不是运行截图或执行证据。

因此,下面这种写法不对:

row, err := db.QueryRowContext(ctx, query, id)
// QueryRowContext 只返回 *sql.Row,错误不会在这里以第二个返回值交付。
if err != nil {
    return err
}

正确的关注点是 Scan 返回值。对于上下文取消、驱动拒绝查询、占位参数不匹配等问题,也要从这个错误返回值向上处理。

用 QueryRowContext 写一个可复用的单行查询函数

把数据库读取封装成小函数后,调用方不必了解列顺序,也不必知道“未命中”在底层表现为哪个错误。示例使用占位参数,不拼接外部输入;这里的 db 是已经由应用初始化并共享的 *sql.DB。

package userrepo

import (
    "context"
    "database/sql"
    "fmt"
)

type User struct {
    ID    int64
    Name  string
    Email string
}

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

    // 占位参数交给驱动绑定,避免把 id 拼进 SQL 文本。
    row := db.QueryRowContext(ctx, query, id)
    // QueryRow 的查询错误、空结果和字段转换错误都在 Scan 处返回。
    if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil {
        return user, fmt.Errorf("find user %d: %w", id, err)
    }
    return user, nil
}

这个函数有两个可复用的优点:查询上下文由调用方传入,可以响应请求取消或超时;错误使用 %w 包装,调用方仍能用 errors.Is 判断底层错误。

把三类错误与资源边界分开处理

实际项目不应把所有 Scan 错误都当成“用户不存在”。至少要区分:没有匹配行、目标字段类型不匹配,以及查询本身或上下文产生的错误。

命中记录、sql.ErrNoRows、Scan 类型错误、驱动或上下文错误与应用返回的静态关系说明图
图2:查询结果、错误分类、应用返回和 sql.DB 连接池的静态关系示意图,不是运行截图或执行证据。
func FindUser(ctx context.Context, db *sql.DB, id int64) (User, error) {
    var user User
    const query = `SELECT id, name, email FROM users WHERE id = ?`

    // QueryRowContext 适合最多返回一条记录的唯一键查询。
    err := db.QueryRowContext(ctx, query, id).Scan(
        &user.ID,
        &user.Name,
        &user.Email,
    )
    switch {
    case err == nil:
        // 命中记录,返回完整对象。
        return user, nil
    case errors.Is(err, sql.ErrNoRows):
        // 空结果是正常业务分支,转换成调用方能理解的语义。
        return User{}, fmt.Errorf("user %d not found: %w", id, sql.ErrNoRows)
    default:
        // 类型转换、驱动、上下文和 SQL 错误不能伪装成未找到。
        return User{}, fmt.Errorf("query user %d: %w", id, err)
    }
}

上面的片段需要在导入区加入 errors。如果项目要返回自定义的 ErrUserNotFound,可以在业务层再包一层;关键是保留可判断的错误链,不要只把错误改成一段无法识别的字符串。

QueryRow 不需要 Rows.Close,但 DB 和 Stmt 仍有生命周期

QueryRowContext 返回的是 *sql.Row,调用方没有一个需要手动关闭的 *sql.Rows。所以不要为了“释放 QueryRow 资源”写不存在的 row.Close(),也不要把单行查询改成遍历循环来绕开错误处理。

需要管理的是更长生命周期的对象:

  • *sql.DB 通常代表连接池,由应用创建并复用;进程停止或该组件不再使用时调用 db.Close()。
  • 如果使用 PrepareContext 创建了 *sql.Stmt,应在语句生命周期结束时调用 stmt.Close()。
  • 如果改用 QueryContext 查询多行,必须按 rows.Close()、rows.Scan() 和 rows.Err() 的契约处理结果。
func prepareUserQuery(ctx context.Context, db *sql.DB) (*sql.Stmt, error) {
    // 预编译语句适合会被重复调用的固定 SQL。
    stmt, err := db.PrepareContext(ctx, `SELECT id, name, email FROM users WHERE id = ?`)
    if err != nil {
        return nil, fmt.Errorf("prepare user query: %w", err)
    }
    // 由调用方在整个使用周期结束后负责 stmt.Close。
    return stmt, nil
}

func FindUserWithStmt(ctx context.Context, stmt *sql.Stmt, id int64) (User, error) {
    var user User
    // Prepared Statement 也把查询错误延迟到 Scan。
    err := stmt.QueryRowContext(ctx, id).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        return User{}, fmt.Errorf("find user with statement: %w", err)
    }
    return user, nil
}

一张表记住 QueryRow 的选择边界

现象推荐处理不要做什么
按唯一键查到一条QueryRowContext(...).Scan(...),返回对象先 Query 再手写只取第一行的循环
没有匹配记录用 errors.Is(err, sql.ErrNoRows) 转成领域语义把空结果误记成数据库故障
字段转换失败或驱动报错包装后向上返回,保留错误链统一改写成“记录不存在”
查询多行改用 QueryContext,处理 Rows 生命周期依赖 QueryRow 丢弃剩余行
复用预编译语句关闭 *sql.Stmt,调用 Stmt.QueryRowContext每次请求都创建而不关闭语句

常见疑问

QueryRow 返回 nil 时该怎么处理?

标准库的 QueryRow 和 QueryRowContext 会返回非 nil 的 *sql.Row;查询错误延迟到 Scan。因此应该检查 Scan 的错误,而不是围绕 row == nil 设计主流程。

为什么不能只判断 err == sql.ErrNoRows?

直接比较只适用于错误没有被包装的情况。仓储函数通常会给错误补充查询和主键上下文,所以调用方应使用 errors.Is,这样既能识别 sql.ErrNoRows,又不会丢掉上下文。

QueryRow 和 QueryRowContext 怎么选?

没有请求上下文时可以使用 QueryRow;服务端请求、后台任务和可取消操作优先使用 QueryRowContext,把取消和超时传到数据库调用。

QueryRow 能保证 SQL 只返回一行吗?

不能。它表达的是调用方只需要一行;如果查询实际匹配多行,Scan 使用第一行。需要唯一性保证时,仍应通过数据库约束或明确的查询条件保证数据模型成立。

记住一句话:QueryRowContext 负责表达“最多取一行”,Scan 负责交付结果和错误,sql.ErrNoRows 负责区分未命中,Rows.Close 只属于多行查询。把这四个概念分开,单行读取代码就不会再被资源释放和错误判断搅在一起。

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