在 Go 服务层中如何优雅地管理事务?

在分层架构设计中,我们强调将业务逻辑与基础设施细节(如数据库访问)解耦。但在实际开发中,事务管理却常常打破这种分层的边界,成为架构设计中的一大挑战。

「分层架构中的事务困境」

在很多 Go 项目中,常见的做法是将数据库连接或事务对象直接传递到服务层(service layer),由服务层主导事务的开启、提交与回滚。

这种实现虽然直接有效,但也被视为一种事务泄漏的反模式(Transaction Leakage Anti-pattern):

增加了耦合度,服务层感知了事务对象,意味着数据访问层的实现细节被暴露。

虽然这是被广泛使用的一种"反模式",但在很多实际 Go 项目中我们还是能看到这种实现。

「聚合视角下的仓库设计」

先来聊一聊 Go 项目中另一个常见的误区:一张数据库表对应一个仓库(Repository)。这种方式在领域驱动设计中被认为是次优的。

领域驱动设计提出"聚合(Aggregate)"的概念:一组需要保持一致性的数据,应该视为一个逻辑单元进行统一管理。如果你按照表设计仓库,最终业务逻辑需要频繁跨多个仓库调用,进而需要复杂的事务共享逻辑。

因此,更好的做法是:

为每个聚合设计一个仓库(即使它涉及多个表)。

聚合内部事务由仓库统一处理。

「将事务逻辑封装在仓库中」

既然事务属于数据访问逻辑的一部分,那就应该将事务的生命周期管理放在仓库内部。下面是我们采用的解决方案:

go 复制代码
// domain/user.go

type User struct {
 ID   int    `gorm:"column:id" json:"id"`
 Name string `gorm:"column:name" json:"name"`
 Tags []Tag  `gorm:"foreignKey:UserID" json:"tags"`
}

func (u User) TableName() string {
 return "user"
}

type IUserRepo interface {
 Tx(ctx context.Context, f UserRepoTxFunc) error
 WithByID(id int) DBOption
 WithByName(name string) DBOption
 GetUser(ctx context.Context, opts ...DBOption) (User, error)
 CreateUser(ctx context.Context, user *User) error
 CreateTags(ctx context.Context, tags []Tag) error
}

type UserRepoTxFunc = func(ctx context.Context, repo IUserRepo) error
type DBOption func(*gorm.DB) *gorm.DB
c 复制代码
// domain/user_tag.go

type Tag struct {
 ID     int    `gorm:"column:id" json:"id"`
 UserID int    `gorm:"column:user_id" json:"user_id"`
 Tag    string `gorm:"column:tag" json:"tag"`
}

func (u Tag) TableName() string {
 return "tag"
}

首先在 repo 中,我们可以像上面这样定义,提供一个 Tx 方法,这个方法接受一个 UserRepoTxFunc 作为参数,这个函数中的 repo 是开启了事务的 repo,通过这个 repo 调用的所有方法都是在事务中执行的。

go 复制代码
// repo/user.go

func NewUserRepo(db *gorm.DB) domain.IUserRepo {
 return &user{db: db}
}

type user struct {
 db *gorm.DB
}

func (r *user) Tx(ctx context.Context, f domain.UserRepoTxFunc) error {
 return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
  repo := NewUserRepo(tx)
  return f(ctx, repo)
 })
}

func (r *user) CreateUser(ctx context.Context, user *domain.User) error {
 return r.db.WithContext(ctx).Create(user).Error
}

func (r *user) CreateTags(ctx context.Context, tags []domain.Tag) error {
 return r.db.WithContext(ctx).Create(&tags).Error
}

func (r *user) WithByID(id int) domain.DBOption {
 return func(db *gorm.DB) *gorm.DB {
  return db.Where("id = ?", id)
 }
}

func (r *user) WithByName(name string) domain.DBOption {
 return func(db *gorm.DB) *gorm.DB {
  return db.Where("name = ?", name)
 }
}

func (r *user) GetUser(ctx context.Context, opts ...domain.DBOption) (domain.User, error) {
 var u domain.User
 db := r.db.WithContext(ctx)
 for _, opt := range opts {
  db = opt(db)
 }
 if err := db.Preload("Tags").Limit(1).Find(&u).Error; err != nil {
  return domain.User{}, err
 }
 return u, nil
}

服务层只需提供聚合级别的操作需求,仓库内部自动处理事务。在 usecase 中这样调用:

go 复制代码
// usecase/user.go

func NewUser(repo domain.IUserRepo) *User {
 return &User{repo: repo}
}

type User struct {
 repo domain.IUserRepo
}

func (u *User) CreateUser(ctx context.Context, user domain.User, tags []string) error {
 return u.repo.Tx(ctx, func(ctx context.Context, repo domain.IUserRepo) error {
  err := repo.CreateUser(ctx, &user)
  if err != nil {
   return err
  }

  if len(tags) == 0 {
   return nil
  }

  var uts []domain.Tag
  for _, v := range tags {
   uts = append(uts, domain.Tag{
    UserID: user.ID,
    Tag:    v,
   })
  }

  return repo.CreateTags(ctx, uts)
 })
}

虽然上述方案并不完美(比如当前事务封装只适用于单一仓库),但对于一些业务场景已足够解决了。

另外一点,上述代码也实现了可组合的查询选项,在 usecase 可以这样调用:

go 复制代码
// usecase/user.go

func (u *User) GetUserByID(ctx context.Context, ID int) (domain.User, error) {
 return u.repo.GetUser(ctx, u.repo.WithByID(ID))
}
相关推荐
nice_lcj52039 分钟前
【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端
java·后端·spring·spring ai·ai应用开发
卷无止境40 分钟前
mathmodel-figure-templates 以及两个上手案例
后端·python
Moment41 分钟前
写给迷茫的应届生,怎样在 AI 时代争取更多机会
前端·后端·面试
回家路上绕了弯43 分钟前
为什么还是不能直接把需求丢给 AI?
后端
锋行天下1 小时前
服务器 Python 2.7 装不了 langgraph-cli?用本地生成 Dockerfile 曲线救国
人工智能·后端·python
桦说编程2 小时前
TransmittableThreadLocal 解读:从 CRR 机制到上下文所有权
java·后端·源码
喵个咪2 小时前
GoWind 拆解:全链 ID 类型体系——int64 定版与前端字符串化契约
前端·后端·go
喵个咪2 小时前
GoWind CMS 拆解:发布审批——多级推进、逐级角色与全程留痕
前端·后端·go
喵个咪3 小时前
GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器
前端·后端·go
喵个咪3 小时前
GoWind CMS 拆解:搜索三板斧——高亮、联想、同义词
后端·搜索引擎·go