database/sql 是 Go 标准库里非常重要的包。它的 API 看起来不复杂,但初学者常常在几个地方踩坑:查询没有超时、忘记关闭 Rows、把 context.Background() 写在仓储层、扫描错误处理不完整。短期看只是代码能不能跑,长期看会影响连接池、请求取消和线上稳定性。
本文围绕一个“按状态查询任务列表”的接口,讲 QueryContext、RowContext、连接释放和超时传播。示例不绑定某个数据库驱动,重点是标准库用法。
从接口传下来的 context
HTTP handler 里天然有请求 context:
func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
tasks, err := h.store.ListByStatus(r.Context(), status)
if err != nil {
http.Error(w, "query tasks failed", http.StatusInternalServerError)
return
}
writeJSON(w, tasks)
}
仓储层不要自己创建 context.Background():
func (s *Store) ListByStatus(ctx context.Context, status string) ([]Task, error) {
// 使用传入 ctx
}
这样客户端断开、网关取消、服务超时都能传到数据库调用。如果仓储层偷偷用 background,上层已经不需要结果了,数据库查询还会继续占着连接和资源。
使用 QueryContext
查询多行数据:
func (s *Store) ListByStatus(ctx context.Context, status string) ([]Task, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, title, status, created_at
FROM tasks
WHERE status = ?
ORDER BY id DESC
LIMIT 100
`, status)
if err != nil {
return nil, fmt.Errorf("query tasks: %w", err)
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var task Task
if err := rows.Scan(&task.ID, &task.Title, &task.Status, &task.CreatedAt); err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
tasks = append(tasks, task)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate tasks: %w", err)
}
return tasks, nil
}
这里有三个细节。第一,QueryContext 使用传入 context。第二,defer rows.Close() 必须写,确保连接归还给连接池。第三,循环结束后要检查 rows.Err(),因为迭代过程中也可能出错。
单行查询和 ErrNoRows
查询单个任务可以用 QueryRowContext:
func (s *Store) GetTask(ctx context.Context, id int64) (Task, error) {
var task Task
err := s.db.QueryRowContext(ctx, `
SELECT id, title, status, created_at
FROM tasks
WHERE id = ?
`, id).Scan(&task.ID, &task.Title, &task.Status, &task.CreatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Task{}, ErrTaskNotFound
}
return Task{}, fmt.Errorf("get task: %w", err)
}
return task, nil
}
不要把 sql.ErrNoRows 直接漏到 handler。仓储层可以把它转换成业务错误,比如 ErrTaskNotFound。handler 再把业务错误映射成 404:
if errors.Is(err, ErrTaskNotFound) {
http.Error(w, "task not found", http.StatusNotFound)
return
}
这样 HTTP 层不需要知道底层用了 SQL。
给数据库调用设置局部超时
如果上层请求允许 3 秒,但某个查询最多只能跑 500 毫秒,可以在仓储层基于传入 context 派生:
func (s *Store) ListByStatus(ctx context.Context, status string) ([]Task, error) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT id, title FROM tasks WHERE status = ?`, status)
// ...
}
注意是从 ctx 派生,不是从 context.Background() 派生。这样上层取消仍然有效。局部超时不要随便写死在很多地方,最好来自配置或集中常量,否则排查慢查询时会不知道哪个层提前取消。
连接泄漏的表现
忘记 rows.Close() 时,连接可能无法及时归还连接池。线上表现可能是请求越来越慢,最后报连接池等待超时。这个问题不一定在本地复现,因为本地并发低,数据库也快。
一个常见错误是提前 return 时漏掉关闭:
rows, err := db.QueryContext(ctx, query)
if err != nil {
return err
}
for rows.Next() {
if somethingBad {
return errors.New("bad") // 如果没有 defer rows.Close,就泄漏
}
}
所以拿到 rows 后尽快 defer rows.Close() 是好习惯。即使后面扫描失败或提前返回,也能释放资源。
不要拼接用户输入
查询条件要用参数,不要字符串拼接:
// 不推荐
query := "SELECT id FROM tasks WHERE status = '" + status + "'"
使用占位符:
rows, err := db.QueryContext(ctx,
"SELECT id FROM tasks WHERE status = ?",
status,
)
不同数据库驱动占位符不同,PostgreSQL 常见 $1,MySQL 和 SQLite 常见 ?。无论占位符长什么样,原则都是一样的:用户输入作为参数传给驱动,不要直接拼进 SQL 字符串。
查询结果要有限制
入门示例常常忘记分页:
SELECT id, title FROM tasks WHERE status = ?
如果表里有几十万行,这个接口会很危险。即使只是后台页面,也应该加 LIMIT,并设计分页或游标:
SELECT id, title
FROM tasks
WHERE status = ? AND id < ?
ORDER BY id DESC
LIMIT 100
数据库上下文能取消慢查询,但更好的方式是不要发起过大的查询。超时是保护网,查询设计才是根本。
小结
Go 的 database/sql 使用 context 时,核心原则是从入口传递 ctx,在查询里使用 QueryContext 或 QueryRowContext,拿到 Rows 后及时关闭,循环后检查 rows.Err()。单行不存在要转换成业务错误,不要把 SQL 细节泄漏到 HTTP 层。
数据库代码看似离用户很远,但它决定了请求取消、连接池和慢查询的行为。初学阶段把这些小习惯养成,后面写服务会稳很多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。