数据库代码要先写清楚,再谈封装
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 执行写操作,事务用 BeginTx、Commit、Rollback。所有数据库调用都应该接收 context,并为错误加上业务上下文。
数据库代码最重要的是清楚和安全。显式列名,参数化查询,关闭 rows,检查 rows.Err(),处理 sql.ErrNoRows,事务范围保持小。这些习惯比一开始就套复杂封装更重要。
等你熟悉了标准库,再去使用 ORM 或查询构建器,会更知道它们帮你省了什么,也更容易判断它们是否适合当前项目。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。