Go SQL 事务入门:把转账、订单和库存写得更可靠

本文用转账和订单扣库存示例讲解 Go database/sql 事务的基本模式,包括 BeginTx、defer Rollback、Commit 和上下文传播。

事务保护的是一组必须一起成功的动作

数据库事务的核心不是“高级数据库功能”,而是一个很朴素的需求:几步操作要么全部成功,要么全部失败。转账时,从 A 扣钱和给 B 加钱必须一起发生;创建订单时,写订单和扣库存也必须保持一致。如果前一步成功,后一步失败,就会留下脏数据。

Go 标准库 database/sql 提供了事务 API:BeginTx、Commit、Rollback。入门阶段最重要的是掌握固定模式:开始事务,defer Rollback 兜底,所有 SQL 走 tx,最后 Commit。

这篇文章用两个例子讲事务写法。

基本事务骨架

func DoInTx(ctx context.Context, db *sql.DB) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	// use tx.QueryContext / tx.ExecContext here

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit tx: %w", err)
	}
	return nil
}

defer tx.Rollback() 即使在 Commit 成功后也会执行,但通常会返回一个可忽略的错误。它的作用是保证中途任何 return 都能回滚。

事务里一定要使用 tx.ExecContext、tx.QueryContext,不要不小心用回 db.ExecContext,否则那条 SQL 不在事务里。

转账示例

func Transfer(ctx context.Context, db *sql.DB, fromID, toID int64, cents int64) error {
	if cents <= 0 {
		return fmt.Errorf("amount must be positive")
	}

	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	result, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?`,
		cents, fromID, cents,
	)
	if err != nil {
		return fmt.Errorf("decrease balance: %w", err)
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("check decrease result: %w", err)
	}
	if affected != 1 {
		return fmt.Errorf("insufficient balance")
	}

	if _, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance + ? WHERE id = ?`,
		cents, toID,
	); err != nil {
		return fmt.Errorf("increase balance: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit transfer: %w", err)
	}
	return nil
}

扣款 SQL 里加了 balance >= ?,并检查影响行数。这能避免余额不足时扣成负数。真实金融系统还要考虑更多一致性、审计和幂等问题,但这个例子已经展示了事务基本结构。

事务里不要做慢外部调用

不要这样:

tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()

tx.ExecContext(ctx, `INSERT INTO orders ...`)
callPaymentProvider()
tx.ExecContext(ctx, `UPDATE orders ...`)
tx.Commit()

外部支付接口可能很慢,事务会长时间占用数据库连接和锁。更好的设计通常是先创建订单,再通过明确状态机和回调处理支付结果。事务只包住必须一起提交的数据库操作。

事务越短越好。它应该保护一致性,不应该把网络调用、文件上传、发送邮件这类不受数据库控制的动作包进去。

把事务边界放在 Service 层

事务通常不应该散落在 HTTP handler 里。Handler 负责 HTTP,Service 负责业务流程,Store 负责 SQL。一个创建订单流程可以这样组织:

type OrderService struct {
	db *sql.DB
}

func (s *OrderService) CreateOrder(ctx context.Context, input CreateOrderInput) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	if err := insertOrder(ctx, tx, input); err != nil {
		return err
	}
	if err := decreaseStock(ctx, tx, input.SKU, input.Quantity); err != nil {
		return err
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit order: %w", err)
	}
	return nil
}

Store 函数接收一个能执行 SQL 的接口:

type Execer interface {
	ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
}

这样它既可以接收 *sql.DB,也可以接收 *sql.Tx。业务流程决定是否开启事务,底层 SQL 函数只负责执行语句。这个边界在项目变大后会很有用。

测试事务代码

事务代码至少要测失败回滚。可以用测试数据库,也可以把 Store 抽象成接口做单元测试。入门阶段最关键的是确保失败时不会继续提交。比如扣库存失败后,订单不应该存在。

不要只测成功路径。事务的价值恰恰在失败路径里体现。

隔离级别先了解,不要乱调

BeginTx 的第二个参数可以传事务选项:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelReadCommitted,
	ReadOnly:  false,
})

不同数据库对隔离级别的支持和默认值不完全一样。入门阶段不要为了“更安全”随便把隔离级别调到最高。更高隔离可能带来更多锁等待和性能问题,也不一定解决你的业务 bug。

多数业务可以先使用数据库默认隔离级别,并通过正确 SQL 条件、唯一约束、行锁或乐观锁保护关键规则。比如扣库存时检查库存数量:

UPDATE products
SET stock = stock - ?
WHERE sku = ? AND stock >= ?

然后检查 RowsAffected。这比单纯依赖应用层先查库存再更新更可靠。

事务正确性往往来自数据库约束、SQL 条件和事务边界共同作用,而不是某一个神奇参数。遇到并发一致性问题时,要结合具体数据库文档和业务场景分析。

常见问题 FAQ

Q: defer tx.Rollback() 在 Commit 后执行会报错吗?
A: Commit 后的 Rollback 会返回一个可忽略的错误(通常是 sql.ErrTxDone)。这是正常行为,不用 panic。

Q: 事务里的查询会锁住表吗?
A: 取决于数据库和隔离级别。MySQL InnoDB 默认行锁,不是表锁。但大事务或大范围扫描可能升级锁粒度。

Q: 事务里能用 db.Query 吗?
A: 不能。事务里所有操作必须用 tx 对象执行,否则那条 SQL 在事务外运行,破坏原子性。

常见陷阱

  1. tx 和 db 混用:在事务里不小心用了 db.ExecContext,导致部分操作在事务外执行。
  2. 事务里做外部 API 调用:支付接口、发送邮件等不能包在事务里。它们不受数据库回滚影响。
  3. 事务太大:长时间持有事务连接,耗尽连接池,拖垮整个服务。

对比表

模式原子性适用场景
单条 SQL语句级简单插入/更新
显式事务多语句原子转账、下单
存储过程数据库端原子复杂业务规则

小结

Go 事务代码的基本模式是 BeginTx、defer Rollback、使用 tx 执行所有 SQL、最后 Commit。错误要带上下文,RowsAffected 要检查,事务范围要尽量短。

事务不是万能锁,也不是越大越安全。它保护的是一组数据库操作的一致性。把边界想清楚,Go 的 database/sql 足够写出可靠的入门事务代码。关键金句是:事务只做数据库操作,没有外部调用,失败时全盘回滚。

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./... 和 golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroup 和 context 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

嵌套事务

Go 的 database/sql 不支持真正的嵌套事务,但可以通过 savepoint 模拟:

func nestedTx(ctx context.Context) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()
    
    if _, err := tx.ExecContext(ctx, `SAVEPOINT sp1`); err != nil {
        return err
    }
    
    if err := riskyOperation(ctx, tx); err != nil {
        tx.ExecContext(ctx, `ROLLBACK TO sp1`)
        // continue with other operations
    }
    
    return tx.Commit()
}

Savepoint 能让部分操作回滚而保留之前的提交。但不同数据库的 savepoint 语法和语义有差异,使用时要确认具体文档。

连接池与事务

BeginTx 从连接池中获取一个连接,这个连接在事务期间专属于该事务。其他请求不会复用这个连接。这意味着:

  1. 事务过长会占用连接池资源,影响并发。
  2. 长时间等待外部 API(如支付网关)不应该在事务内进行。
  3. 事务结束后连接归还到池中,可以继续复用。

合理设置连接池大小 (db.SetMaxOpenConns) 和事务超时 (ctx.WithTimeout) 是保证系统稳定的关键。

乐观锁

对于并发更新场景,可以用数据库版本号做乐观锁:

result, err := tx.ExecContext(ctx,
    `UPDATE accounts SET balance = balance - ?, version = version + 1 WHERE id = ? AND version = ?`,
    amount, id, expectedVersion,
)
affected, _ := result.RowsAffected()
if affected == 0 {
    return ErrOptimisticLockFailed
}

乐观锁不阻塞读,只在写时检测冲突。适合读多写少的场景。

总结

database/sql 的事务 API 虽然简单,但已经覆盖了绝大多数业务需求。核心原则是:事务要尽快结束,不要包含 IO 或外部调用。把事务边界放在 service 层而非 handler 层,确保数据一致性由业务逻辑保障。配合 savepoint、乐观锁和连接池管理,可以构建可靠的数据访问层。

事务中的只读查询

如果事务中只有查询没有修改,可以标记为 ReadOnly 提高效率:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
    ReadOnly:  true,
})

ReadOnly 事务不会获取写锁,也不会记录到 WAL(视数据库实现而定),通常并发性能更好。但要注意:标记为 ReadOnly 后如果执行写操作,数据库会报错。

事务超时

txCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

tx, err := db.BeginTx(txCtx, nil)

事务超时后 context 会被取消,后续的数据库操作都会返回 context deadline exceeded。这是一种被动超时机制——超时不主动中断正在执行的 SQL,而是让 SQL 执行完后被 cancel。MySQL 的超时可能需要配合 SET SESSION MAX_EXECUTION_TIME 等设置。

测试事务的隔离性

func TestIsolation(t *testing.T) {
    tx1, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    tx2, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    
    tx1.ExecContext(ctx, `UPDATE accounts SET balance = 100 WHERE id = 1`)
    
    // tx2 should block or fail depending on database
    _, err := tx2.ExecContext(ctx, `UPDATE accounts SET balance = 200 WHERE id = 1`)
    // test behavior
    
    tx1.Rollback()
    tx2.Rollback()
}

隔离级别测试依赖具体数据库实现,跨数据库测试时结果可能不同。单元测试中通常只对业务语义做隔离级别验证,不对数据库本身行为做断言。

总结

database/sql 的事务设计虽然简单,但覆盖了绝大多数业务场景。核心原则是尽快提交、不包外部 IO、错误时回滚。再结合 savepoint、乐观锁、连接池管理和合理的事务超时设置,可以构建出可靠且高效的数据访问层。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页