写 Go 数据库代码时,我们经常用 ExecContext 和 QueryContext 传 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。
预编译是数据库访问优化手段,不是默认答案。先写清楚参数绑定、事务和校验,再根据重复执行场景决定是否使用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。