Go 数据库事务实战——GORM 事务、嵌套事务、Savepoint 完整方案
Go 数据库事务实战——GORM 事务、嵌套事务、Savepoint 完整方案
适读人群:用 GORM 做业务开发、需要处理复杂事务场景的 Go 工程师 | 阅读时长:约16分钟 | 核心价值:把 GORM 事务的每一个细节都搞清楚,不只是基础的 Begin/Commit/Rollback
一个差点上线的数据不一致 bug
大概8个月前,我在做一个"团队邀请"功能:用户发送邀请后,需要创建一条邀请记录,同时给被邀请人发一条站内通知,并更新邀请发送计数器。
我写了这么一段代码(简化版):
func (s *TeamService) InviteMember(ctx context.Context, req InviteRequest) error {
// 创建邀请记录
invite := &Invite{TeamID: req.TeamID, Email: req.Email}
if err := s.db.Create(invite).Error; err != nil {
return err
}
// 创建通知
notification := &Notification{UserEmail: req.Email, Content: "你被邀请加入团队"}
if err := s.db.Create(notification).Error; err != nil {
// 邀请已创建,通知失败——数据不一致!
return err
}
// 更新计数器
if err := s.db.Model(&Team{}).Where("id = ?", req.TeamID).
UpdateColumn("invite_count", gorm.Expr("invite_count + 1")).Error; err != nil {
// 邀请和通知已创建,计数器失败——数据不一致!
return err
}
return nil
}这段代码在生产上如果通知创建失败,就会出现:邀请记录已创建但没有通知,然后逻辑层认为邀请失败,下次重试时又创建一条新的邀请记录。
幸好这个 bug 在测试中被发现,但差点上线。
GORM 事务基础
方式一:手动控制事务
func (s *TeamService) InviteMember(ctx context.Context, req InviteRequest) error {
// Begin 开始事务,tx 是带事务的 *gorm.DB
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return fmt.Errorf("begin transaction: %w", tx.Error)
}
// 使用 defer + recover 确保 panic 时也能回滚
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r) // 重新 panic,让上层处理
}
}()
// 所有操作都用 tx,不用 s.db
invite := &Invite{TeamID: req.TeamID, Email: req.Email}
if err := tx.Create(invite).Error; err != nil {
tx.Rollback()
return fmt.Errorf("create invite: %w", err)
}
notification := &Notification{UserEmail: req.Email, Content: "你被邀请加入团队"}
if err := tx.Create(notification).Error; err != nil {
tx.Rollback()
return fmt.Errorf("create notification: %w", err)
}
if err := tx.Model(&Team{}).Where("id = ?", req.TeamID).
UpdateColumn("invite_count", gorm.Expr("invite_count + 1")).Error; err != nil {
tx.Rollback()
return fmt.Errorf("update invite count: %w", err)
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}这种方式有个问题:每次写事务代码都要手动 Rollback,容易漏写。
方式二:Transaction 闭包(推荐)
func (s *TeamService) InviteMember(ctx context.Context, req InviteRequest) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 在这个函数里的所有操作都在同一个事务里
// 返回 nil 则 Commit,返回 error 则 Rollback
invite := &Invite{TeamID: req.TeamID, Email: req.Email}
if err := tx.Create(invite).Error; err != nil {
return fmt.Errorf("create invite: %w", err)
}
notification := &Notification{
UserEmail: req.Email,
Content: fmt.Sprintf("你被邀请加入团队 %s", req.TeamName),
}
if err := tx.Create(notification).Error; err != nil {
return fmt.Errorf("create notification: %w", err)
}
if err := tx.Model(&Team{}).Where("id = ?", req.TeamID).
UpdateColumn("invite_count", gorm.Expr("invite_count + 1")).Error; err != nil {
return fmt.Errorf("update invite count: %w", err)
}
return nil
})
}这种方式更安全,GORM 内部帮你处理 Rollback。
踩坑实录
坑一:事务传播问题——service 调用 service 时事务断裂
现象: A 服务方法调用 B 服务方法,A 开启了事务,但 B 里的操作用的是 s.db(非事务版),导致 A 回滚时 B 的操作没有回滚。
原因: Go 没有 Spring 那样的 @Transactional 注解可以自动传播事务。事务上下文需要显式传递。
解法: 把 tx *gorm.DB 作为参数传递,或者把 tx 放在 context.Context 里:
package repository
import (
"context"
"gorm.io/gorm"
)
// 用 context key 存储事务
type txKey struct{}
// WithTx 把事务放入 context
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
return context.WithValue(ctx, txKey{}, tx)
}
// GetTx 从 context 获取事务,如果没有则返回普通 db
func GetTx(ctx context.Context, db *gorm.DB) *gorm.DB {
if tx, ok := ctx.Value(txKey{}).(*gorm.DB); ok && tx != nil {
return tx
}
return db
}
// UserRepository 示例
type UserRepository struct {
db *gorm.DB
}
func (r *UserRepository) Create(ctx context.Context, user *User) error {
// 自动使用 context 里的事务(如果有的话)
db := GetTx(ctx, r.db)
return db.Create(user).Error
}// service 层
func (s *TeamService) CreateTeamWithOwner(ctx context.Context, req CreateTeamRequest) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 把事务放入 context,传递给下游的所有方法
txCtx := repository.WithTx(ctx, tx)
// userRepo.Create 会自动用 tx
owner := &User{Name: req.OwnerName, Email: req.OwnerEmail}
if err := s.userRepo.Create(txCtx, owner); err != nil {
return err
}
// teamRepo.Create 也会自动用 tx
team := &Team{Name: req.TeamName, OwnerID: owner.ID}
if err := s.teamRepo.Create(txCtx, team); err != nil {
return err
}
return nil
})
}坑二:嵌套事务——用 SavePoint 实现部分回滚
现象: 主流程要做 A、B、C 三步,C 失败了只想回滚 C,不想让 A 和 B 也失败。
原因: 普通的事务要么全提交要么全回滚,做不到"部分回滚"。
解法: Savepoint。GORM 支持 Savepoint:
package main
import (
"context"
"fmt"
"gorm.io/gorm"
)
// ProcessOrderWithOptionalFeatures 处理订单,可选功能失败不影响主流程
func ProcessOrderWithOptionalFeatures(ctx context.Context, db *gorm.DB, orderID string) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 主流程:创建订单记录(必须成功)
order := &Order{ID: orderID, Status: "processing"}
if err := tx.Create(order).Error; err != nil {
return fmt.Errorf("create order: %w", err)
}
// 设置 Savepoint,可选功能失败时回滚到这里
if err := tx.SavePoint("before_optional").Error; err != nil {
return fmt.Errorf("savepoint: %w", err)
}
// 可选功能:创建积分奖励(失败可以接受)
reward := &PointReward{OrderID: orderID, Points: 100}
if err := tx.Create(reward).Error; err != nil {
// 回滚到 Savepoint,保留主流程的修改
if rbErr := tx.RollbackTo("before_optional").Error; rbErr != nil {
return fmt.Errorf("rollback to savepoint: %w", rbErr)
}
// 记录日志,但不返回错误(主流程继续)
fmt.Printf("create reward failed (non-fatal): %v\n", err)
}
// 更新库存(必须成功)
if err := tx.Model(&Inventory{}).
Where("product_id = ?", order.ProductID).
UpdateColumn("stock", gorm.Expr("stock - 1")).Error; err != nil {
return fmt.Errorf("update inventory: %w", err)
}
return nil
})
}坑三:长事务导致连接池被占满
现象: 有一个批量导入操作,把几百条数据放在一个事务里,导入速度慢(每条要做一些外部验证),事务持续了 47 秒,期间这个数据库连接被占住,连接池可用连接减少,其他请求开始等待。
原因: 事务持有数据库连接,长事务意味着长时间占用连接。
解法: 分批提交,不要把大量操作放在一个事务里:
func (s *ImportService) BulkImport(ctx context.Context, items []ImportItem) error {
const batchSize = 50 // 每批次提交的条数
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
batch := items[i:end]
if err := s.importBatch(ctx, batch); err != nil {
return fmt.Errorf("import batch %d-%d: %w", i, end, err)
}
}
return nil
}
func (s *ImportService) importBatch(ctx context.Context, items []ImportItem) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range items {
// 每批次只有 50 条,事务很快就能完成
if err := tx.Create(&Record{Data: item.Data}).Error; err != nil {
return err
}
}
return nil
})
}Go vs Java:Spring @Transactional vs GORM Transaction
Spring 的 @Transactional 是 AOP 实现的,非常方便,加个注解就有事务。但它有一些隐患:同一个类里的方法调用不走 AOP 代理,事务不生效(这是 Spring 的经典坑)。
GORM 的事务是显式的代码逻辑,没有"魔法"。你清楚地知道什么时候开启了事务,什么时候提交,什么时候回滚。通过 context 传播的方式,也比 Spring 的 ThreadLocal 传播更适合 Go 的并发模型。
代价是:你不能偷懒,每次写事务代码都要仔细考虑事务边界。但这种"麻烦"能让你避免很多 Spring 里因为不了解 AOP 传播机制而踩的坑。
