Go database/sql 上下文入门:QueryContext、超时和连接释放

从一次查询接口出发,讲 database/sql 里 context 的使用、QueryContext、Scan、Rows 关闭、超时传播和常见连接泄漏问题。

database/sql 是 Go 标准库里非常重要的包。它的 API 看起来不复杂,但初学者常常在几个地方踩坑:查询没有超时、忘记关闭 Rows、把 context.Background() 写在仓储层、扫描错误处理不完整。短期看只是代码能不能跑,长期看会影响连接池、请求取消和线上稳定性。

本文围绕一个“按状态查询任务列表”的接口,讲 QueryContextRowContext、连接释放和超时传播。示例不绑定某个数据库驱动,重点是标准库用法。

从接口传下来的 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,在查询里使用 QueryContextQueryRowContext,拿到 Rows 后及时关闭,循环后检查 rows.Err()。单行不存在要转换成业务错误,不要把 SQL 细节泄漏到 HTTP 层。

数据库代码看似离用户很远,但它决定了请求取消、连接池和慢查询的行为。初学阶段把这些小习惯养成,后面写服务会稳很多。

继续阅读

探索更多技术文章

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

全部文章 返回首页