Go 事务里的查询为什么不能再使用原来的 DB 句柄
来源:17golang原创
时间:2026-10-06 13:11:49 497浏览 收藏
Go 的 *sql.DB 是连接池句柄,*sql.Tx 才是一次已经开始的事务上下文。事务里的查询如果继续调用原来的 db.QueryContext,数据库驱动可能从连接池拿到另一条连接,这条语句就不再属于当前事务。正确做法是:从 BeginTx 返回 Tx 后,事务内的查询、更新和单行读取都使用 Tx 方法,最后只在 Tx 上 Commit 或 Rollback。
官方地址:https://pkg.go.dev/database/sql#Tx
DB管理连接池,不代表某一条当前事务连接;Tx绑定一次事务和连接。- 事务内使用
tx.QueryContext、tx.ExecContext、tx.QueryRowContext,不要混用db.*。 - 用
defer tx.Rollback()做失败兜底,成功路径再显式Commit;回滚已经提交的事务不会产生二次提交。 Rows.Err和Commit的错误都要返回,不能只判断 SQL 执行函数是否成功。

在开启事务之后,所有和当前事务上下文绑定的读写操作都必须使用事务句柄而非全局原始DB句柄,否则会打破事务的原子性隔离规则,出现意料之外的数据不一致问题。
区分 DB 和 Tx 的连接语义
DB 更像连接池入口:它可以安全地被多个 goroutine 使用,具体语句由驱动和连接池安排。调用 BeginTx 后,返回的 Tx 代表已绑定的事务上下文,事务期间的语句要沿着这个对象执行。
所以“原来的 DB 句柄”并不是事务句柄的别名。它仍然可用,但它执行的语句可能在事务之外、另一条连接上,无法看到当前事务尚未提交的写入,也不会随着当前 Tx 一起回滚。
把事务内读写统一改为 Tx 方法
下面的函数把扣库存和写审计记录放到同一个事务里。业务判断失败时返回错误,延迟回滚负责清理;只有全部操作通过后才提交。
package order
import (
"context"
"database/sql"
"fmt"
)
func CreateOrder(ctx context.Context, db *sql.DB, userID, sku string) error {
// BeginTx 把后续事务操作交给 tx,而不是继续从 db 取连接。
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// 失败路径回滚;提交后再回滚不会把已提交的数据撤销。
defer tx.Rollback()
var stock int
// 事务内读取必须使用 tx,保证读取属于同一事务边界。
if err := tx.QueryRowContext(ctx,
"SELECT stock FROM inventory WHERE sku = ? FOR UPDATE", sku,
).Scan(&stock); err != nil {
return fmt.Errorf("read stock: %w", err)
}
if stock
这里的重点不是 SQL 本身,而是所有依赖同一事务状态的调用都以 tx. 开头。函数参数仍然接收 *sql.DB,因为它负责开始事务;一旦拿到 tx,就不要把它丢给另一个只接收 DB 的辅助函数。
为什么 DB 查询会让结果看起来“不一致”
常见现象是:事务里刚插入一行,随后用 db.QueryRowContext 却查不到;或者事务准备回滚时,某个辅助函数已经通过 db.ExecContext 写入了外部数据。这并不一定是数据库故障,而是两次调用的连接和事务上下文不同。
| 写法 | 实际边界 | 风险 |
|---|---|---|
tx.ExecContext | 当前 Tx 和绑定连接 | 符合事务预期 |
db.ExecContext | 连接池可能选择另一连接 | 写入不随 Tx 回滚 |
db.QueryContext | 事务外查询 | 看不到未提交数据,快照可能不同 |
tx.QueryRowContext | 当前 Tx | 适合读取事务内状态 |
不要用“这两次调用使用了同一个 *sql.DB 指针”来判断它们在同一事务中。DB 指针相同只表示共享连接池,不能证明语句共享事务。

处理提交、回滚和错误路径
事务函数至少要处理三类错误:语句执行错误、读取结果迭代后的 Rows.Err、提交时错误。只要事务还没有明确成功提交,回滚兜底就应该存在。
func CountActive(ctx context.Context, tx *sql.Tx) (int, error) {
// 辅助函数接收 Tx,调用者无法误把查询放到事务外。
rows, err := tx.QueryContext(ctx,
"SELECT status FROM jobs WHERE status = ?", "active",
)
if err != nil {
return 0, fmt.Errorf("query active jobs: %w", err)
}
defer rows.Close()
count := 0
for rows.Next() {
var status string
// Scan 错误应立即返回,让上层回滚事务。
if err := rows.Scan(&status); err != nil {
return 0, fmt.Errorf("scan job: %w", err)
}
count++
}
// 迭代结束后仍要检查驱动在读取过程中报告的错误。
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("iterate jobs: %w", err)
}
return count, nil
}
辅助函数接收 *sql.Tx 还有一个好处:接口签名直接表达了调用约束。若某个函数既能在事务内又能在事务外运行,可以把“执行器”抽象成只包含所需方法的接口,但仍应由调用方明确传入 DB 还是 Tx,避免隐藏连接边界。
代码审查时的混用检查清单
- 事务开始后,搜索同一函数及其辅助函数里是否还出现
db.Query、db.Exec或db.QueryRow。 - 依赖事务状态的辅助函数是否接收
*sql.Tx,而不是再次接收*sql.DB。 - 是否有
defer tx.Rollback()兜底,以及成功路径是否检查Commit错误。 - 遍历
Rows后是否调用Rows.Err,并在错误时把控制权交回事务函数。 - 提交前是否关闭 Rows,避免把仍在使用的结果集带入提交路径。
常见问题
事务开始后还能使用 DB 做不相关查询吗? 可以,但它不属于当前 Tx。只要查询结果参与本事务的判断,就应该改用 Tx 方法。
为什么延迟 Rollback 不会影响成功提交? Commit 成功后事务已经结束,后续 Rollback 只会返回已完成或无效状态;它的作用是覆盖中途 return 的失败路径。
辅助函数必须全部改成接收 Tx 吗? 只有需要共享当前事务状态的函数必须接收 Tx;事务外的独立查询仍可使用 DB,但接口名称和调用边界要区分清楚。
Commit 返回错误时能直接重试吗? 不能盲目重试。提交错误可能意味着连接状态未知,应先记录事务标识和业务幂等键,再由上层按数据库与业务协议决定恢复方式。
-
420 收藏
-
267 收藏
-
133 收藏
-
130 收藏
-
Golang · Go问答 | 2小时前 | go · TLS · 网络安全 · VerifyConnection Go InsecureSkipVerify x509.Verify TLS证书校验 证书固定384 收藏
-
468 收藏
-
223 收藏
-
412 收藏
-
287 收藏
-
290 收藏
-
479 收藏
-
191 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习