Go 数据库入门:用 database/sql 写清楚的查询和事务

本文讲解 Go 标准库 database/sql 的连接池、查询、Scan、Exec、事务和 context 使用,帮助初学者理解数据库访问的基本边界。

数据库代码要先写清楚,再谈封装

Go 做后端服务绕不开数据库。很多人会直接找 ORM,但在理解 ORM 之前,最好先用标准库 database/sql 写几次查询和事务。它能让你看清楚数据库访问的基本动作:打开连接池、执行 SQL、扫描结果、处理空值、使用 context、提交或回滚事务。

database/sql 不是具体数据库驱动,而是一套统一接口。你需要配合 MySQL、PostgreSQL、SQLite 等驱动使用。本文示例用通用 SQL 写法说明概念,不绑定某个驱动细节。真实项目里,驱动导入和占位符可能略有不同,比如 PostgreSQL 常用 $1,MySQL 和 SQLite 常用 ?

重点不是背 API,而是建立边界:Handler 不直接拼 SQL,查询函数返回明确结构体,事务范围尽量小,外部传入 context.Context,错误要带上下文。

打开数据库连接池

典型代码:

db, err := sql.Open("mysql", dsn)
if err != nil {
	return err
}
defer db.Close()

注意 sql.Open 并不一定立刻建立连接,它主要创建连接池句柄。通常还要 PingContext 验证:

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

if err := db.PingContext(ctx); err != nil {
	return fmt.Errorf("ping database: %w", err)
}

配置连接池:

db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)

这些参数没有统一答案,要看数据库能力和服务负载。入门阶段先知道 *sql.DB 是连接池,不是单个连接。它应该在程序启动时创建,整个服务复用,不要每个请求都 sql.Open

查询单行

定义用户结构体:

type User struct {
	ID    int64
	Name  string
	Email string
}

查询函数:

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

	var user User
	err := db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return User{}, fmt.Errorf("user not found")
		}
		return User{}, fmt.Errorf("query user %d: %w", id, err)
	}

	return user, nil
}

QueryRowContext 查询一行,Scan 把列扫描到变量地址。列顺序必须和 Scan 参数顺序一致。SQL 里最好显式列名,不要 SELECT *,否则表结构变化容易影响代码。

如果用户不存在,Scan 返回 sql.ErrNoRows。这是正常业务情况,应该单独处理。

查询多行

func ListUsers(ctx context.Context, db *sql.DB, limit int) ([]User, error) {
	const query = `
SELECT id, name, email
FROM users
ORDER BY id DESC
LIMIT ?
`

	rows, err := db.QueryContext(ctx, query, limit)
	if err != nil {
		return nil, fmt.Errorf("query users: %w", err)
	}
	defer rows.Close()

	users := make([]User, 0)
	for rows.Next() {
		var user User
		if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil {
			return nil, fmt.Errorf("scan user: %w", err)
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate users: %w", err)
	}

	return users, nil
}

多行查询有三个容易忘的点:defer rows.Close(),循环里检查 Scan,循环后检查 rows.Err()。最后一步很重要,因为遍历过程中可能发生错误。

返回空列表时,建议返回空切片而不是 nil:

if users == nil {
	users = []User{}
}

这对 JSON API 更友好。

插入和更新

插入:

func CreateUser(ctx context.Context, db *sql.DB, name, email string) (int64, error) {
	const query = `
INSERT INTO users (name, email)
VALUES (?, ?)
`

	result, err := db.ExecContext(ctx, query, name, email)
	if err != nil {
		return 0, fmt.Errorf("insert user: %w", err)
	}

	id, err := result.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("get inserted id: %w", err)
	}
	return id, nil
}

更新:

func UpdateUserName(ctx context.Context, db *sql.DB, id int64, name string) error {
	const query = `UPDATE users SET name = ? WHERE id = ?`

	result, err := db.ExecContext(ctx, query, name, id)
	if err != nil {
		return fmt.Errorf("update user name: %w", err)
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("get affected rows: %w", err)
	}
	if affected == 0 {
		return fmt.Errorf("user not found")
	}

	return nil
}

永远不要用字符串拼接用户输入来构造 SQL:

query := "SELECT * FROM users WHERE email = '" + email + "'"

应该使用参数占位符,让驱动处理转义和绑定。这是防 SQL 注入的基本要求。

事务:要么一起成功,要么一起失败

假设转账:从 A 扣钱,给 B 加钱。两个更新必须在同一个事务里。

func Transfer(ctx context.Context, db *sql.DB, fromID, toID int64, cents int64) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	if _, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance - ? WHERE id = ?`,
		cents, fromID,
	); err != nil {
		return fmt.Errorf("decrease balance: %w", err)
	}

	if _, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance + ? WHERE id = ?`,
		cents, toID,
	); err != nil {
		return fmt.Errorf("increase balance: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit tx: %w", err)
	}
	return nil
}

defer tx.Rollback() 看起来奇怪,因为成功时已经 Commit。提交后再 Rollback 会返回错误,但通常可以忽略。它的作用是保证中途任何 return 都会回滚。

事务里不要做耗时外部调用,比如 HTTP 请求或发送邮件。事务应该尽量短,只包住必须一致的数据库操作。

小结

database/sql 的核心动作不多:启动时创建 *sql.DB 连接池,使用 QueryRowContext 查单行,QueryContext 查多行,ExecContext 执行写操作,事务用 BeginTxCommitRollback。所有数据库调用都应该接收 context,并为错误加上业务上下文。

数据库代码最重要的是清楚和安全。显式列名,参数化查询,关闭 rows,检查 rows.Err(),处理 sql.ErrNoRows,事务范围保持小。这些习惯比一开始就套复杂封装更重要。

等你熟悉了标准库,再去使用 ORM 或查询构建器,会更知道它们帮你省了什么,也更容易判断它们是否适合当前项目。

继续阅读

探索更多技术文章

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

全部文章 返回首页