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

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 执行函数是否成功。
Go database/sql 中 DB 连接池、BeginTx 和 Tx 事务连接边界的静态结构图
图1:Go database/sql 中 DB 与 Tx 的连接语义边界,这是静态结构说明图,不是运行截图。
在开启事务之后,所有和当前事务上下文绑定的读写操作都必须使用事务句柄而非全局原始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 指针相同只表示共享连接池,不能证明语句共享事务。

Go database/sql 事务操作经过 Commit 或 Rollback 的静态关系图
图2:事务成功提交与失败回滚的关系说明图,不是数据库运行截图。

处理提交、回滚和错误路径

事务函数至少要处理三类错误:语句执行错误、读取结果迭代后的 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 返回错误时能直接重试吗? 不能盲目重试。提交错误可能意味着连接状态未知,应先记录事务标识和业务幂等键,再由上层按数据库与业务协议决定恢复方式。

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