Go SQL 预编译入门:PrepareContext 什么时候值得用

用批量插入任务示例讲 database/sql 中 PrepareContext、Stmt、参数绑定、关闭资源和与普通 ExecContext 的取舍。

写 Go 数据库代码时,我们经常用 ExecContextQueryContext 传 SQL 和参数。那 PrepareContext 是做什么的?简单说,它会创建一个预编译语句,后续可以多次执行,适合同一条 SQL 重复执行的场景,比如批量插入、批量更新。

本文用“批量创建任务”讲预编译语句的基本用法、资源关闭和适用边界。

普通 ExecContext

func InsertTask(ctx context.Context, db *sql.DB, task Task) error {
	_, err := db.ExecContext(ctx, `
		INSERT INTO tasks (id, title, status)
		VALUES (?, ?, ?)
	`, task.ID, task.Title, task.Status)
	return err
}

这已经是安全的参数绑定。不要为了预编译才避免 SQL 注入,普通参数化查询也能避免把用户输入拼进 SQL。

批量时使用 PrepareContext

func InsertTasks(ctx context.Context, db *sql.DB, tasks []Task) error {
	stmt, err := db.PrepareContext(ctx, `
		INSERT INTO tasks (id, title, status)
		VALUES (?, ?, ?)
	`)
	if err != nil {
		return fmt.Errorf("prepare insert task: %w", err)
	}
	defer stmt.Close()

	for _, task := range tasks {
		if _, err := stmt.ExecContext(ctx, task.ID, task.Title, task.Status); err != nil {
			return fmt.Errorf("insert task %d: %w", task.ID, err)
		}
	}
	return nil
}

stmt.Close() 很重要。预编译语句可能占用数据库连接或服务端资源。用完要关闭。

和事务一起使用

批量插入通常要事务:

func InsertTasksTx(ctx context.Context, db *sql.DB, tasks []Task) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	stmt, err := tx.PrepareContext(ctx, `
		INSERT INTO tasks (id, title, status)
		VALUES (?, ?, ?)
	`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	for _, task := range tasks {
		if _, err := stmt.ExecContext(ctx, task.ID, task.Title, task.Status); err != nil {
			return err
		}
	}
	return tx.Commit()
}

事务里的 stmt 绑定到事务。不要把事务里创建的 stmt 拿到事务外继续用。事务提交或回滚后,它的生命周期也应该结束。

什么时候值得用

适合:

  • 同一条 SQL 在短时间内执行很多次
  • 批量导入
  • 批量更新
  • 明确希望数据库复用执行计划

不一定需要:

  • 一次性查询
  • 动态 SQL 很多
  • 执行次数很少
  • 代码复杂度超过收益

很多数据库驱动和数据库本身对 prepared statement 的实现细节不同。不要以为用了 Prepare 一定更快。性能敏感时要 benchmark 或压测。

参数仍然要校验

预编译不等于业务校验。比如 title 为空、status 不合法,仍然要在业务层处理:

func validateTask(task Task) error {
	if strings.TrimSpace(task.Title) == "" {
		return errors.New("title is required")
	}
	if task.Status != "open" && task.Status != "done" {
		return errors.New("invalid status")
	}
	return nil
}

SQL 参数绑定解决的是格式和注入问题,不解决业务规则。

不要预编译无限多动态 SQL

如果你根据用户选择动态拼很多不同 SQL,然后每条都 Prepare,可能造成数据库端 statement 数量膨胀。预编译适合稳定 SQL,不适合无限变化的 SQL。动态字段排序、筛选条件应该通过白名单控制,必要时直接 QueryContext 就好。

测试批量插入逻辑

如果项目有数据库集成测试,可以用临时数据库验证事务行为。单元测试层面,可以把 store 封装成接口,业务逻辑不直接依赖 SQL。SQL 本身最好通过集成测试覆盖,因为预编译、占位符和事务行为都和驱动有关。

func TestValidateTask(t *testing.T) {
	if err := validateTask(Task{Title: "", Status: "open"}); err == nil {
		t.Fatal("expected validation error")
	}
}

把业务校验和数据库写入拆开,测试会更轻。

长期持有 Stmt 要谨慎

*sql.Stmt 可以复用,但它不是“越全局越好”。如果系统里有几十个 SQL 都在启动时 prepare,连接池、数据库代理和迁移过程都会更难管理。入门项目可以先在热点路径或批处理里使用 prepared statement,不必把所有查询都改掉。

type UserRepo struct {
	db *sql.DB
}

func (r *UserRepo) ByEmail(ctx context.Context, email string) (User, error) {
	const q = `select id, email, name from users where email = ?`
	var u User
	err := r.db.QueryRowContext(ctx, q, email).Scan(&u.ID, &u.Email, &u.Name)
	return u, err
}

上面这种普通查询仍然是安全的,因为参数通过占位符传入,不是字符串拼接。prepared statement 更适合批量重复执行、数据库端能明显复用执行计划的场景。

占位符因数据库而异

不同数据库的占位符写法不一样。MySQL 常用 ?,PostgreSQL 常用 $1$2。如果你把教程里的 SQL 直接复制到另一个驱动,可能会报语法错误。

// MySQL
const mysqlInsert = `insert into users(email, name) values(?, ?)`

// PostgreSQL
const pgInsert = `insert into users(email, name) values($1, $2)`

这也是为什么仓库层不要到处散落 SQL 字符串。把 SQL 放在相对集中的 repo 方法里,未来换驱动、改字段、加审计列时更容易检查。

批量插入的替代方案

prepared statement 逐条执行很稳,但不是最快方案。如果一次要导入几万行,数据库通常有更高效的批量接口,比如 PostgreSQL 的 COPY,或拼接多行 values。入门阶段先把正确性、事务和错误处理做好,性能瓶颈明确后再换方案。

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

stmt, err := tx.PrepareContext(ctx, `insert into audit_logs(action, actor) values(?, ?)`)
if err != nil {
	return err
}
defer stmt.Close()

for _, item := range items {
	if _, err := stmt.ExecContext(ctx, item.Action, item.Actor); err != nil {
		return err
	}
}

return tx.Commit()

这个版本的优点是清晰:要么整批成功,要么回滚。对管理后台、低频导入和内部工具来说,这种清晰度通常比极致性能更值钱。

小结

PrepareContext 适合同一条 SQL 多次执行的场景,尤其是批量插入和批量更新。使用时要 defer stmt.Close(),事务中的 stmt 不要跨事务使用。普通 ExecContext 加参数已经能安全绑定用户输入,不必为了“防注入”强行 Prepare。

预编译是数据库访问优化手段,不是默认答案。先写清楚参数绑定、事务和校验,再根据重复执行场景决定是否使用。

继续阅读

探索更多技术文章

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

全部文章 返回首页