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 中一个完整事务的生命周期,包括开启、执行、提交/回滚以及连接释放过程:
2.5 事务边界与连接绑定
理解事务与底层连接的绑定关系,是避免并发问题的关键。下图展示了事务、连接与连接池之间的关系:
核心要点:
- 一个
sql.Tx对象独占一个底层数据库连接,事务期间该连接不会被其他 goroutine 使用。 - 事务内的所有操作(Exec、Query、Prepare)都使用同一个连接,保证隔离性。
- 事务提交或回滚后,连接自动归还连接池,变为空闲状态。
- 不要在事务内使用
db.Exec()或db.Query(),这些操作会从连接池获取新连接,不在事务范围内。
2.6 报表回放中的事务实践要点
- 短事务原则: 事务应尽快提交,避免长时间持有连接。报表回放中,建议每个事务只处理一批(如 1000 条)数据。
- 错误处理: 事务内的每个 SQL 操作都必须检查错误,一旦出错立即回滚。
- 重试机制: 对于可重试的错误(如死锁),可以捕获后重新开启事务重试。
- 避免嵌套事务: Go 的
database/sql不支持嵌套事务,如果需要类似功能,可以使用保存点(Savepoint)模拟。
报表回放常见错误: 在循环中逐条开启事务提交,会导致大量短连接,性能极差。正确的做法是批量处理,每批开启一个事务,批量提交。