优雅封装事务

gorm中事务封装

Posted by jarvis on Mon, May 22, 2023

事务

简单介绍一下事务(Transaction),事务指的是一系列操作,作为一个单独的逻辑工作单元来执行,要么完全执行,要么完全不执行。在数据库系统中,事务通常由一组读写操作组成,在这样的系统中,事务具有四个基本特性,即ACID:

  • 原子性(Atomicity):事务是一个原子操作,要么全部提交,要么全部回滚,不能只完成其中一部分。
  • 一致性(Consistency):事务执行前后,数据库的状态必须保持一致,如果事务执行失败,则需要回滚到事务开始之前的状态。
  • 隔离性(Isolation):并发执行的多个事务之间相互隔离,一个事务的执行不会影响其他事务的执行。
  • 持久性(Durability):事务执行成功后,其结果必须永久保存到数据库中,即使发生系统故障也不能丢失。

通过使用事务,可以确保数据库的数据的一致性和可靠性,同时也可以防止数据的不一致和错误,提高了数据库系统的可靠性和安全性。

GORM中使用事务

为了确保数据一致性,GORM 会在事务里执行写入操作(创建、更新、删除)。如果没有这方面的要求,可以在初始化时禁用它,这将获得大约 30%+ 性能提升。

1// 全局禁用
2db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
3  SkipDefaultTransaction: true,
4})

在gorm中使用事务的一般流程如下:

 1func CreateAnimals(db *gorm.DB) error {
 2  // 开始事务
 3  tx := db.Begin()
 4  defer func() {
 5    if r := recover(); r != nil {
 6      // 遇到错误时回滚事务
 7      tx.Rollback()
 8    }
 9  }()
10
11  if err := tx.Error; err != nil {
12    return err
13  }
14
15  // 在事务中执行一些 db 操作(从这里开始,您应该使用 'tx' 而不是 'db')
16  if err := tx.Create(&Animal{Name: "Giraffe"}).Error; err != nil {
17     tx.Rollback()
18     return err
19  }
20
21  if err := tx.Create(&Animal{Name: "Lion"}).Error; err != nil {
22     tx.Rollback()
23     return err
24  }
25
26  // 提交事务
27  return tx.Commit().Error
28}

从这个示例看似乎没什么问题,但结合显示的一些业务场景再来看一下会有什么问题。就拿在线购物来说,假设你正在购买一件商品,并且需要进行以下几个操作:

  1. 将商品添加到购物车中;
  2. 将订单创建并记录在系统中;
  3. 从用户账户中扣除相应金额;
  4. 更新库存。

根据第一个示例可能会这么写:

 1func (d *Dao) Shop(product Product) error {
 2  // 开始事务
 3  tx := d.db.Begin()
 4  defer func() {
 5    if r := recover(); r != nil {
 6      // 遇到错误时回滚事务
 7      tx.Rollback()
 8    }
 9  }()
10
11  if err := tx.Error; err != nil {
12    return err
13  }
14
15  // 添加购物车
16  if err := tx.AddToCart(product).Error; err != nil {
17     tx.Rollback()
18     return err
19  }
20  // 创建订单
21  if err := tx.CreateOrder(product).Error; err != nil {
22     tx.Rollback()
23     return err
24  }
25  // 付款
26  if err := tx.Pay(product).Error; err != nil {
27     tx.Rollback()
28     return err
29  }
30  // 更新库存
31  if err := tx.UpdateInventory(product).Error; err != nil {
32     tx.Rollback()
33     return err
34  }
35
36  // 提交事务
37  return tx.Commit().Error
38}

这样看起来是没什么毛病,但是如果这个用户只添加了购物车呢,或者添加了购物车并创建了订单没有付款呢,又需要组装几个新的实现?这样就太繁琐了,重复的代码就会很多。也有的人会在service层创建tx传到dao层,在service层组装需求,类似这样:

 1func (s *Service) Shop(product Product) error {
 2  // 开始事务
 3  tx := s.dao.Db.Begin()
 4  defer func() {
 5    if r := recover(); r != nil {
 6      // 遇到错误时回滚事务
 7      tx.Rollback()
 8    }
 9  }()
10
11  if err := tx.Error; err != nil {
12    return err
13  }
14
15  // 添加购物车
16  if err := s.dao.AddToCart(tx, product).Error; err != nil {
17     tx.Rollback()
18     return err
19  }
20  // 创建订单
21  if err := s.dao.CreateOrder(tx, product).Error; err != nil {
22     tx.Rollback()
23     return err
24  }
25  // 付款
26  if err := tx.Pay(product).Error; err != nil {
27     tx.Rollback()
28     return err
29  }
30  // 更新库存
31  if err := s.dao.UpdateInventory(tx, product).Error; err != nil {
32     tx.Rollback()
33     return err
34  }
35
36  // 提交事务
37  return tx.Commit().Error
38}

这样做会有两个问题,一个是tx被显示传递,不够优雅并且在service层出现了dao中的变量,破坏了我们的框架的分层原则;二是当业务的不确定性很大,不能灵活的满足需求,即使能满足也会出现冗余的代码。

优雅的封装事务

针对上述的问题,既然不想tx被显示传递,我们可以将它包到Context中,然后通过参数来决定事务需要处理的方法。使用Context还有一个好处就是,如果后续做链路追踪或者超时控制也非常方便。接下来先对事务进行封装:

 1package transaction
 2
 3import (
 4	"context"
 5	"gorm.io/gorm"
 6	"log"
 7	"reflect"
 8)
 9
10type TransactionControl struct {
11	db             *gorm.DB
12	transactionCtx struct{}
13}
14
15//在dao中创建事务控制器
16func NewTransactionControl(db *gorm.DB) TransactionControl {
17	return TransactionControl{db: db}
18}
19
20//从ctx中获取tx,此方法在dao中使用
21func (t *TransactionControl) GetCtxDB(ctx context.Context) *gorm.DB {
22  //使用结构体而不是字符串作为 ctx 的 key,可以保证 key 的唯一性
23	ctxDb := ctx.Value(t.transactionCtx)
24
25	if ctxDb != nil {
26		tx, ok := ctxDb.(*gorm.DB)
27		if !ok {
28			log.Panicf("unexpect context value type: %s", reflect.TypeOf(tx))
29			return nil
30		}
31		return tx
32	}
33  //如果tx不存在,则创建新的session
34	return t.db.WithContext(ctx)
35}
36
37//将tx包装到ctx中
38func (t *TransactionControl) CtxWithTransaction(ctx context.Context, tx *gorm.DB) context.Context {
39	if ctx == nil {
40		ctx = context.Background()
41	}
42	return context.WithValue(ctx, t.transactionCtx, tx)
43}
44
45//事务的处理,funcs是dao层的方法
46func (t *TransactionControl) Transaction(ctx context.Context, funcs ...func(txCtx context.Context) error) (err error) {
47	tx := t.db.Begin()
48	defer func() {
49		if r := recover(); r != nil {
50			tx.Rollback()
51		}
52	}()
53  //将事务包装到ctx中
54	txCtx := t.CtxWithTransaction(ctx, tx)
55	for _, f := range funcs {
56		if err = f(txCtx); err != nil {
57			tx.Rollback()
58			return
59		}
60	}
61	if err = tx.Commit().Error; err != nil {
62		tx.Rollback()
63	}
64	return
65}

我们总体的思路是将tx包装到Context中,通过GetCtxDB方法获取tx并且实现db操作,然后将需要事务的方法通过参数传递,在Transaction中真正的提交事务。

我们还是使用网购这个例子来看一下我们封装的事务,首先我们需要在dao层创建事务控制器,并且定义一些网购相关的方法:

 1type Dao struct {
 2	MDb *gorm.DB
 3	transaction.TransactionControl
 4}
 5
 6func New() *Dao {
 7	mdb := db.GetMysqlDB()
 8	return &Dao{
 9		MDb:                mdb,
10		TransactionControl: transaction.NewTransactionControl(mdb),
11	}
12}
13
14func (d *Dao) AddToCart(ctx context.Context, product Product) error {
15  return d.GetCtxDB(ctx).Table("product").Create(&product).Error
16}
17
18func (d *Dao) CreateOrder(ctx context.Context, product Product) error {
19  return d.GetCtxDB(ctx).Table("order").Create(&Product).Error
20}
21
22func (d *Dao) Pay(ctx context.Context, product Product) error {
23  //付款
24}
25
26func (d *Dao) UpdateInventory(ctx context.Context, product Product) error {
27  //更新库存
28}

我们在service层拼装我们的业务逻辑:

 1func (s *Service) Shop(product Product) error {
 2	var addToCartFunc = func(txCtx context.Context) error {
 3			return s.dao.AddToCart(txCtx, product)
 4	}
 5	var createOrderFunc = func(txCtx context.Context) error {
 6			return s.dao.CreateOrder(txCtx, product)
 7	}
 8  var payFunc = func(txCtx context.Context) error {
 9			return s.dao.Pay(txCtx, product)
10	}
11  var updateInventoryFunc = func(txCtx context.Context) error {
12			return s.dao.UpdateInventory(txCtx, product)
13	}
14	return err := s.dao.Transaction(context.Background(), addToCartFunc, createOrderFunc, payFunc, updateInventoryFunc)
15}

即使只有添加购物车操作,我们只需要在service判断逻辑就可以了,不需要在修改dao层的数据获取,非常的灵活。