莱西网站建设哪家好十大免费域名
在写代码过程中遇到了需要使用gorm执行sql事务的情况,研究了一下各位大佬的实现方案,结合了自身遇到的问题,特此记录。
代码架构介绍
.
├── apis
├── config
├── internal
│ ├── constant
│ ├── controller
│ ├── logic
│ ├── model
│ ├── repo
│ ├── router
│ └── service
├── library
├── pkg
├── scripts
目录结构其实很简单,主要参考了goframe的架构。internal/logic放逻辑代码,internal/repo是dao层,负责与数据库进行交互。
演变过程
最开始的时候是直接把对事务操作放在repo层中了。比如说有两张表,一张用户消费记录表,一张用户积分表,在用户消费完成后就要增加对应的积分。那么在repo层就会有一个函数负责开启事务,写消费记录表,写积分表,提交事务。在logic层中只需要调用这个函数就可以同时完成这两个操作。
看起来很美好,逻辑分的很清晰,但是这么做其实有个问题。如果logic层中有逻辑只操作消费记录表呢,或者用户消费积分只操作积分表呢,或者又有其他的表需要进行联合操作。这种写法会在repo层不断增加一个又一个函数,导致函数越来越多,因为承载了过多的业务逻辑。
发现这种情况之后,我们想可不可以把业务逻辑从repo层抽离,放在logic层进行处理,repo层只做简单的增删改查。那么repo层就可以只实现几个基础的函数,Create(),Update(),Selete()等,然后在logic层通过调用这几个函数实现复杂的逻辑操作。还是之前那个例子,比如说先消费再增加积分就可以变成对两个函数调用。
// logic 层func buy() {tx := db.begin()cosume.Create(tx)point.Update(tx)tx.commit()}
分层是明确了,但是又引出了新的问题,就是如果我想使用事务,那么就需要在logic层对事务进行开启和提交,并且logic层还需要保存db的连接(用于开启事务),并且将tx传给repo层进行操作,这不合理。logic应该专注于代码逻辑的处理,而不是如何开启一个数据库事务。
目标很明确,如何让logic层只关注与逻辑的操作,而不是开启事务。上文也说到repo层只做一些简单的增删改查的操作,在repo里实现是不可能了,那么只能采取终极方案,在logic和repo之间增加一层中间层,专门用于开启事务。
这个中间层更像一个管理层,用于管理具体操作数据库的repo类和事务的操作,当需要开启事务时,logic只需要编写好业务逻辑,然后调用管理层的接口。管理层负责开启事务,调用logic层编写好的逻辑,提交/回滚事务即可。
这样logic层既不用处理事务相关的任务,repo层也不需要处理复杂的业务逻辑。
实现方案
总结一下上面所说,我们需要实现一个管理类,在管理类里面提供一个事务接口,这个接口的入参有一个func,这个func就是logic层编写好的各种逻辑,为了将tx传递给repo,我们是将其放在了ctx中。
管理层示例
Internal/repo/transaction.go
这个代码参考的时 gorm.Transaction(),做了简单修改。注释写的很详细了。
// Mysql Mysql数据库
type Mysql struct {db *gorm.DB
}func (m *Mysql) Transaction(ctx context.Context, fc func(txCtx context.Context) error) error {panicked := truevar err error// 拿到 ctx 里面的 db 连接,如果没有则使用默认的 db 连接db := m.getDB(ctx)// ...// 使用这个连接开启事务tx := db.WithContext(ctx).Begin()if tx.Error != nil {return tx.Error}defer func() {// Make sure to rollback when panic, Block error or Commit errorif panicked || err != nil {tx.Rollback()}}()// 将事务的 tx 写入 ctx 中,为了真正操作数据库的函数拿到 txtxCtx := generateContextWithDB(ctx, tx)// 调用 logic 层传入的 funcif err = fc(txCtx); err == nil {panicked = false// 如果没有报错就提交事务return tx.Commit().Error}panicked = false// 把事务内的报错转发出去return err
}
调用示例
/internal/logic/manager.go
func (m *manager) Buy(ctx context.Context) error {// 调用 Transaction 函数,txCtx 则为带有 tx 的 ctxif err := m.mysql.Transaction(ctx, func(txCtx context.Context) {// 这里只需要实现对应的业务即可,无需关注事务的开启和提交/回滚逻辑m.mysql.Consume(txCtx).Create()m.mysql.Point(txCtx).Update()}); err != nil {// do something}return nil
}
从 manager 这个函数的角度来看,整个事务执行的逻辑为,通过 Transaction 第一个参数 ctx 获取要执行事务的 db 连接,开启事务后将 tx 封到 context 中,然后传入 Transaction 第二个参数 func() 的入参中。通过将 txCtx 传入到不同的 repo 类中来实现各个 repo 使用同一个 tx 进行事务的操作。Transaction 函数保证了中间有任何错误都会进行回滚和无错误的提交操作。
Consume()和Point()做的是解析 txCtx 中的 db 连接,然后 new 一个repo 类返回。getDB() 负责返回 ctx 里的 db 接。
func (m *Mysql) Consume(ctx context.Context) ConsumeRepo {return NewConsumeRepo(m.getDB(ctx))
}
整体的代码逻辑就是这样,如果需要支持多个引擎,其实可以考虑抽出一个interface,各个引擎实现这个 interface 的接口即可。
gorm 一些写法
多表联查
我们的表设计的不是很规范,导致表和表之间基本上没有什么关联,外键也没有,所以没有办法使用gorm提供的preload,采取的是手动join的方式。
func (c *ConsumePoint) SelectForUpdate(ctx context.Context) (*model.ConsumeAndPoint, error) {// 接收数据的结构var record model.ConsumeAndPointif err := c.db.WithContext(ctx).// 主表Table("t_consume").// 返回什么Select("t_consume.*, t_point.f_point").// 连接方式Joins("join t_point on t_consume.f_user_id = t_point.f_user_id").// 只查询一条数据First(&record).Error; err != nil {// do something}return &record, nil
}
select for update
先读后更新的数据竞争且应该将加锁操作放到事务中,防止锁被自动释放,其实主要就在于 Set(“gorm:query_option”, “FOR UPDATE”) 这一行。如果采用上面多表联查的方式,设置了 FOR UPDATE 后两张表的内容都会有X锁,直到事务提交。可以用这个特性来搞分布式的竞争更新。
func UpdateUser(db *gorm.DB, id int64) error {tx := db.Begin()defer func() {if r := recover(); r != nil {tx.Rollback()}}()if err := tx.Error; err != nil {return err}user := User{}// 锁住指定 id 的 User 记录if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, id).Error; err != nil {tx.Rollback()return err}// 更新操作...// commit事务,释放锁if err := tx.Commit().Error; err != nil {return err}return nil
}