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

事务已经回滚却仍占用连接,常见的资源遗漏在哪里

来源:17golang原创

时间:2026-10-08 12:30:02 215浏览 收藏

不少Go开发者在使用database/sql或者各类ORM写事务逻辑时,经常碰到这种很反常的情况:明明已经调用了事务的Rollback方法完成回滚,但是数据库连接池里对应的连接始终没有归还,一直被占用,攒多之后整个连接池就会被耗尽,后续所有新请求都卡在拿连接的步骤。这类现象基本都不是数据库驱动或者标准库的bug,大多是事务生命周期里的资源漏处理导致的。

事务回滚后仍占住连接,本质是事务对象本身没有被正常销毁,关联的连接不会被自动归还到连接池,所有遗漏的资源点都集中在事务执行流程里的异常分支没做统一兜底回收。

Go 服务里最容易误判的一幕是:业务日志已经打印“事务回滚”,连接池的 InUse 却没有马上下降。先抓住结论:Tx.Rollback 只结束事务本身;如果查询结果集仍在遍历、某条路径没有执行回滚,或者你看到的是连接池等待而非连接泄漏,现象都会看起来像“回滚后连接还占着”。排查时要把 Tx、Rows 和 DB.Stats 分开看。

最稳妥的处理方式是:事务创建成功后立即登记 defer tx.Rollback(),每次查询及时收口 Rows 并检查迭代错误,最后只把 Commit 返回 nil 当作提交成功。

本文使用标准库 database/sql 的小型订单写入场景,代码是结构示例,不代表某个驱动或数据库的运行截图。

先区分事务结束与连接归还

DB 是连接池句柄,不是一条固定连接。调用 BeginTx 后,返回的 Tx 会绑定一条连接;事务结束时,这条连接才有机会回到池中。对已经成功回滚的事务再次操作会得到 sql.ErrTxDone,这说明事务对象已结束,而不是说明所有外围查询对象都自动替你处理完毕。

Go database/sql 中事务、绑定连接和连接池状态的结构说明图
图1:事务绑定连接并在 Commit 或 Rollback 后回到连接池的结构说明图,不是运行截图。

因此,看到 InUse 短时间不变时,先记录时间窗口,再同时看 Idle、WaitCount 和 WaitDuration。慢 SQL 会让连接仍在使用;并发高时,等待数上涨也不等于连接泄漏。

按资源生命周期收口错误路径

事务代码的关键不是把 Rollback 写在某一个错误分支,而是让所有提前返回都经过同一个兜底点。提交成功后,延迟回滚会被忽略;失败时则负责把未完成事务收口。

func createOrder(ctx context.Context, db *sql.DB, userID int64) error {
    // 用请求上下文限制数据库操作的最长生命周期。
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback() // Commit 成功后这里会被安全忽略。

    if _, err = tx.ExecContext(ctx,
        "INSERT INTO orders(user_id, status) VALUES (?, ?)", userID, "pending"); err != nil {
        // 直接返回也没关系,defer 会结束未提交事务。
        return fmt.Errorf("insert order: %w", err)
    }

    if _, err = tx.ExecContext(ctx,
        "UPDATE users SET order_count = order_count + 1 WHERE id = ?", userID); err != nil {
        // 第二个写操作失败时,不能继续提交部分结果。
        return fmt.Errorf("update user: %w", err)
    }

    if err = tx.Commit(); err != nil {
        // Commit 失败时不要把结果当作确定成功,交给上层按未知状态处理。
        return fmt.Errorf("commit order: %w", err)
    }
    return nil
}

这里的 defer tx.Rollback() 不是“重复回滚”。它把遗漏的早退路径统一兜底;真正需要重点检查的是是否存在把 tx 传给 goroutine、等待外部通道后才结束,或在返回前忘记调用 Commit 的路径。

Rows、Stmt 和 Scan 也要分别收口

“已经回滚”不能代替结果集的生命周期管理。尤其是事务里先查询再决定是否更新时,如果在 for rows.Next() 中途返回,应该显式关闭 Rows,并在正常循环后检查 rows.Err()。事务创建的预处理语句会在事务提交或回滚时关闭,但跨事务准备的 Stmt 仍要由创建者负责关闭。

Go Rows、Scan、Close 和事务收口关系的结构说明图
图2:Rows 查询结果、Scan 错误和 Close 边界的结构说明图,不是运行截图。
func hasPendingItems(ctx context.Context, tx *sql.Tx, userID int64) (bool, error) {
    // 结果集可能占用事务绑定的连接,创建后立即安排关闭。
    rows, err := tx.QueryContext(ctx,
        "SELECT status FROM orders WHERE user_id = ?", userID)
    if err != nil {
        return false, fmt.Errorf("query orders: %w", err)
    }
    defer rows.Close() // 中途 Scan 失败或提前返回时释放结果集。

    for rows.Next() {
        var status string
        if err := rows.Scan(&status); err != nil {
            return false, fmt.Errorf("scan order status: %w", err)
        }
        if status == "pending" {
            return true, nil
        }
    }
    if err := rows.Err(); err != nil {
        // Next 返回 false 既可能是结束,也可能是迭代错误。
        return false, fmt.Errorf("iterate orders: %w", err)
    }
    return false, nil
}

用连接池指标排除误判

可以定时采样 db.Stats(),但不要只盯一个数字。InUse 表示正在使用的连接,Idle 表示空闲连接;WaitCount 和 WaitDuration 更适合观察并发请求是否在等待池中的连接。若 InUse 长时间接近 MaxOpenConnections 且请求路径有未结束的事务,再回到 BeginTx、Rows.Close 和 goroutine 退出路径逐项定位。

现象优先检查不要直接下的结论
回滚后 InUse 短暂不降慢查询、Rows 是否仍在迭代一定是泄漏
WaitCount 持续上涨MaxOpenConns、SQL 时延、事务范围一定是忘记 Close
连接长期打满所有早退路径、goroutine 生命周期、驱动日志只补一行 Rollback 就能解决

排查清单与边界

按这个顺序检查通常最快:一是 BeginTx 成功后是否立刻注册回滚兜底;二是每个 QueryContext 返回的 Rows 是否在同一函数内关闭;三是循环结束后是否检查 Err;四是提交错误是否被记录并按未知状态处理;五是是否有 goroutine 持有 Tx 或 Rows 跨请求存活。最后要区分数据库驱动行为:连接池指标能帮助定位趋势,但不能替代驱动和数据库端的会话观测。

常见问题

回滚后还能调用 tx.Exec 吗?不能把它当作可继续使用的事务。事务结束后再操作通常返回 sql.ErrTxDone,需要重新开始新的事务。

Rows 遍历完还需要 Close 吗?完整遍历且没有更多结果集时,标准库会自动关闭;但统一写 defer rows.Close() 能覆盖提前返回路径,仍应检查 rows.Err()。

把“事务已经回滚”和“所有资源都已释放”拆开观察,通常就能从模糊的连接占用现象,收敛到具体的生命周期遗漏。

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