2. Go 事务基础:ACID 特性与 database/sql 包

在报表回放系统中,事务是保证数据一致性的核心机制。本章从数据库事务的 ACID 理论出发,介绍 Go 标准库 database/sql 包的事务操作接口,并演示事务的开启、提交与回滚。

2.1 数据库事务的 ACID 特性

事务(Transaction)是数据库执行过程中的一个逻辑单元,由一组 SQL 语句组成。事务必须满足四个基本特性,即 ACID:

特性 全称 说明 报表场景示例
A 原子性 (Atomicity) 事务中的所有操作要么全部成功,要么全部失败回滚 报表生成时,若写入明细表成功但汇总表失败,整个事务回滚
C 一致性 (Consistency) 事务执行前后,数据库的完整性约束不被破坏 报表数据必须满足外键、唯一约束等业务规则
I 隔离性 (Isolation) 并发执行的事务之间互不干扰 两个报表回放任务同时写入同一张表时,数据不互相污染
D 持久性 (Durability) 已提交的事务对数据库的修改是永久的 报表数据一旦提交,即使系统崩溃也不会丢失
关键理解: 在报表回放场景中,ACID 的 原子性 和 隔离性 最为关键。原子性保证回放步骤不出现“半写”状态;隔离性保证多个回放任务并发时数据不混乱。

2.2 Go 中 database/sql 包简介

Go 标准库 database/sql 提供了统一的 SQL 数据库操作接口,不绑定具体数据库驱动。开发者只需导入对应驱动(如 MySQL 的 github.com/go-sql-driver/mysql),即可通过相同的 API 操作不同数据库。

2.2.1 核心接口与对象

对象 说明 线程安全
sql.DB 数据库连接池,管理底层连接 是
sql.Conn 从连接池获取的单个连接 否
sql.Tx 事务对象,绑定到一个连接 否
sql.Stmt 预编译语句,可绑定到 DB 或 Tx 是
sql.Rows 查询结果集 否
重要提示: sql.DB 是线程安全的,可以全局共享。但 sql.Tx 和 sql.Conn 不是线程安全的,必须在同一个 goroutine 中使用。

2.2.2 连接数据库示例

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql" // 注册 MySQL 驱动
)

func main() {
    // 数据源名称格式:user:password@tcp(host:port)/dbname?charset=utf8mb4
    dsn := "report_user:report_pass@tcp(127.0.0.1:3306)/report_db?charset=utf8mb4&parseTime=true"
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        panic("数据库连接失败: " + err.Error())
    }
    defer db.Close()

    // 设置连接池参数
    db.SetMaxOpenConns(25)  // 最大打开连接数
    db.SetMaxIdleConns(5)   // 最大空闲连接数
    db.SetConnMaxLifetime(5 * time.Minute) // 连接最大存活时间

    // 测试连接是否正常
    if err := db.Ping(); err != nil {
        panic("数据库 Ping 失败: " + err.Error())
    }
}

2.3 事务开启与提交

在 database/sql 包中,事务通过 db.Begin() 开启,通过 tx.Commit() 提交,通过 tx.Rollback() 回滚。事务内的所有 SQL 操作都使用 tx 对象执行,而不是 db 对象。

2.3.1 事务基本流程

func transferFunds(db *sql.DB, fromID, toID int, amount float64) error {
    // 1. 开启事务
    tx, err := db.Begin()
    if err != nil {
        return fmt.Errorf("开启事务失败: %w", err)
    }
    // 2. 确保在函数退出时回滚(如果尚未提交)
    defer tx.Rollback()

    // 3. 执行事务内的 SQL 操作
    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
    if err != nil {
        return fmt.Errorf("扣款失败: %w", err)
    }

    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
    if err != nil {
        return fmt.Errorf("加款失败: %w", err)
    }

    // 4. 提交事务
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("提交事务失败: %w", err)
    }
    return nil
}
常见陷阱: 事务开启后,defer tx.Rollback() 必须放在 tx.Commit() 之前。如果 Commit 成功,Rollback 调用会被忽略(返回 sql.ErrTxDone 错误),不会造成影响。但如果忘记 defer Rollback,事务失败时连接不会被释放,导致连接泄漏。

2.3.2 事务隔离级别设置

Go 的 database/sql 支持通过 db.BeginTx() 方法设置事务隔离级别:

import (
    "database/sql"
    "context"
)

func beginWithIsolation(db *sql.DB) (*sql.Tx, error) {
    ctx := context.Background()
    opts := &sql.TxOptions{
        Isolation: sql.LevelRepeatableRead, // 可重复读
        ReadOnly:  false,
    }
    tx, err := db.BeginTx(ctx, opts)
    if err != nil {
        return nil, err
    }
    return tx, nil
}

Go 支持的隔离级别常量:

常量 隔离级别 脏读 不可重复读 幻读
sql.LevelDefault 数据库默认 取决于数据库 取决于数据库 取决于数据库
sql.LevelReadUncommitted 读未提交 可能 可能 可能
sql.LevelReadCommitted 读已提交 否 可能 可能
sql.LevelRepeatableRead 可重复读 否 否 可能
sql.LevelSerializable 可串行化 否 否 否
报表场景建议: 报表回放通常使用 sql.LevelRepeatableRead(可重复读),确保在事务内多次读取同一数据结果一致,避免因其他事务的更新导致报表数据不一致。

2.4 事务生命周期 SVG 图

下图展示了 Go 中一个完整事务的生命周期,包括开启、执行、提交/回滚以及连接释放过程:

Go 事务生命周期 sql.DB 连接池 获取连接 tx, err := db.Begin() 执行操作 tx.Exec() / tx.Query() 决策 成功? 是 tx.Commit() 否 tx.Rollback() 连接归还连接池 defer tx.Rollback() 确保函数退出时 自动回滚未提交事务

2.5 事务边界与连接绑定

理解事务与底层连接的绑定关系,是避免并发问题的关键。下图展示了事务、连接与连接池之间的关系:

事务与连接绑定关系 sql.DB 连接池 连接 1(空闲) 连接 2(事务中) 连接 3(空闲) sql.Tx 对象 绑定到连接 2 独占绑定 事务内操作(同一连接) tx.Exec("INSERT ...") tx.Exec("UPDATE ...") tx.Query("SELECT ...") tx.Prepare("...") Commit() / Rollback() 连接 2 归还连接池
核心要点:
  • 一个 sql.Tx 对象独占一个底层数据库连接,事务期间该连接不会被其他 goroutine 使用。
  • 事务内的所有操作(Exec、Query、Prepare)都使用同一个连接,保证隔离性。
  • 事务提交或回滚后,连接自动归还连接池,变为空闲状态。
  • 不要在事务内使用 db.Exec() 或 db.Query(),这些操作会从连接池获取新连接,不在事务范围内。

2.6 报表回放中的事务实践要点

  • 短事务原则: 事务应尽快提交,避免长时间持有连接。报表回放中,建议每个事务只处理一批(如 1000 条)数据。
  • 错误处理: 事务内的每个 SQL 操作都必须检查错误,一旦出错立即回滚。
  • 重试机制: 对于可重试的错误(如死锁),可以捕获后重新开启事务重试。
  • 避免嵌套事务: Go 的 database/sql 不支持嵌套事务,如果需要类似功能,可以使用保存点(Savepoint)模拟。
报表回放常见错误: 在循环中逐条开启事务提交,会导致大量短连接,性能极差。正确的做法是批量处理,每批开启一个事务,批量提交。