登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go database/sql 怎样按批次提交写入

来源:17golang原创

时间:2026-10-18 21:05:21 242浏览 收藏

Go 用 database/sql 批量写入时,最稳妥的组织方式是“一个批次对应一个事务”:先按固定数量切出记录,在这个事务里复用一条预编译语句,批内任意写入失败就回滚,全部成功才提交。这样既不会让一笔大事务长期占住连接,也不会把每一行都拆成一次提交。

官方资料:https://pkg.go.dev/database/sql

本文只讨论事务范围、预编译语句和资源释放,不绑定某个数据库驱动的方言或性能数字。

为什么批次边界要落在事务上

*sql.DB 是由标准库管理的数据库句柄,内部负责连接池;而 sql.Tx 表示一次事务范围。批量写入如果从头到尾只使用一个事务,数据量变大后,锁、日志和连接占用都会跟着扩大;如果每行单独提交,又会让提交开销重复出现。

把记录切成若干批次后,边界就变得清楚:批次内的写入共享同一个 sql.Tx,批次之间互不共用事务。成功边界是 Commit,失败边界是 Rollback,下一批重新建立自己的事务对象。

输入记录切成批次后由 sql.DB 管理 sql.Tx 和 Stmt 并关联数据库写入的静态结构说明图
图1:批次与事务边界的静态结构说明图,不是截图或运行证据。

一个批次怎样复用事务和预编译语句

实现时可以把“处理一个批次”封装成闭包或小函数。事务在批次函数内部创建,语句也在事务内部准备;不要把事务级 Stmt 跨批次保存,因为它的生命周期应当跟随当前事务。

// InsertProducts 按 batchSize 分批写入,每批独立提交事务。
func InsertProducts(ctx context.Context, db *sql.DB, rows []Product, batchSize int) error {
	if batchSize  len(rows) {
			end = len(rows) // 最后一批允许不足 batchSize。
		}

		batchErr := func() (err error) {
			tx, err := db.BeginTx(ctx, nil)
			if err != nil {
				return fmt.Errorf("begin batch [%d:%d]: %w", start, end, err)
			}
			committed := false
			defer func() {
				if !committed {
					_ = tx.Rollback() // 准备或写入失败时撤销当前批次。
				}
			}()

			stmt, err := tx.PrepareContext(ctx,
				"INSERT INTO products (id, name, price) VALUES (?, ?, ?)",
			)
			if err != nil {
				return fmt.Errorf("prepare batch [%d:%d]: %w", start, end, err)
			}
			defer stmt.Close() // 语句跟随当前事务释放,不泄漏资源。

			for _, row := range rows[start:end] {
				if _, err = stmt.ExecContext(ctx, row.ID, row.Name, row.Price); err != nil {
					return fmt.Errorf("insert id %d: %w", row.ID, err)
				}
			}
			if err = tx.Commit(); err != nil {
				return fmt.Errorf("commit batch [%d:%d]: %w", start, end, err)
			}
			committed = true
			return nil
		}()

		if batchErr != nil {
			return batchErr // 当前批次失败后停止,避免继续写入后续批次。
		}
	}
	return nil
}

这里的 defer 只负责兜底回滚,成功提交后由 committed 阻止重复回滚;stmt.Close 则保证预编译语句不会随着批次循环不断累积。最后一批直接使用实际的 end,不需要为了凑满批次补空数据。

出错时怎样保住事务边界

需要重点区分三类错误:事务无法开始时,当前批次尚未写入;语句准备或某一行执行失败时,当前批次应回滚;提交失败时,调用方应把错误向上返回,不要擅自把这一批标记成成功。示例使用 ExecContext 让调用方可以通过上下文取消较慢的写入。

批次范围关联 ExecContext、错误返回、Rollback、Commit 和 Stmt.Close 的静态资源生命周期说明图
图2:批量写入的错误处理与资源生命周期静态说明图,不是截图或运行证据。

不要只在循环外写一次 defer tx.Rollback() 就忽略提交结果,也不要在事务中途遇到错误后继续执行下一行。错误一旦返回,当前批次的结论就应固定为失败;如果业务需要重试,应由上层根据错误类型和幂等设计决定,而不是在这个函数里无条件重放。

批次大小和资源释放怎么取舍

批次大小没有适用于所有数据库的固定数值,可以先从一个容易观察的中等范围开始,再依据单批耗时、锁持有时间、事务日志和驱动参数限制调整。更重要的是保持边界一致:批次切片、事务、预编译语句和提交结果应属于同一个生命周期。

关注点建议原因
事务范围每个批次独立 BeginTx/Commit避免单个事务无限扩大
语句复用批内 PrepareContext 一次避免每行重复准备
上下文为整批设置合理截止时间取消时能结束数据库操作
连接池结合并发批次数设置上限批量写入仍会占用连接
驱动参数确认占位符和批量 SQL 限制database/sql 不替驱动统一方言

如果批次很大,优先观察单批失败后的重试成本;如果批次很小,优先观察提交次数和连接占用。文章中的事务边界是结构性建议,不代表某个驱动或数据库的实测性能保证。

常见问题

可以把全部记录放进一个事务吗?

可以,但只适合数据量可控、锁和日志压力可接受的场景。需要长时间运行或允许部分批次成功时,按批次提交更容易控制失败范围。

每一行都调用 PrepareContext 可以吗?

不建议。重复准备会增加开销,也让资源生命周期更难管理;对同一批次的固定 INSERT,通常在批次事务内准备一次并循环执行即可。

Commit 返回错误后还能自动再提交吗?

不要在函数内部无条件重试。提交错误可能意味着服务端状态未知,应先记录批次标识和错误,由上层结合唯一键、幂等策略和驱动文档决定后续动作。

总结

Go database/sql 的批量写入可以用四个动作稳定下来:切分批次、批内开启事务并复用语句、失败立即回滚、成功才提交。配合 context.Context、stmt.Close 和明确的批次大小,写入逻辑就能在吞吐、失败范围和资源释放之间保持可解释的边界。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>