事务 API
到目前为止,已经解决了增删改查的问题,是时候步入到事务阶段了,对于事务来说,核心就是要允许用户创建事务,然后 在事务内部执行增删改查。
开源实例
Beego ORM
Beego ORM 这里提供了两种类型的事务接口:
- 一种是用户自己控制的,
- 另一种是框架控制的(将执行语句打包传进框架提供的指定的事务方法, 这里指的是 Do 开头的事务方法)
对外暴露的操作事务的提交或回滚的接口,可以理解成这是与用户自己控制的 Begin 开头的事务接口配合使用的。
GORM
- DB 本身也可以被看做是事务
- 普通的事务开启、提交和回滚功能
- 额外实现了一个 SavePoint 的功能
- 事务闭包 API Transaction
API 设计
目标:
- 开启事务
- 回滚或者提交事务
- 闭包 API
- 不准备支持 SavePoint 的功能
Tx 定义
事务的核心 API:
- Begin:开始一个事务
- Commit:提交一个事务
- Rollback:回滚一个事务
需要定义一个新的结构体来表达事务的含义 , 这里本文引入全新的 Tx 来表达事务,和 GORM 的设计是很不一样的。这意味着 DB 在创建好之后,就是一个不可变的对象。
type Tx struct {
tx *sql.Tx
db *DB
}
func (t *Tx) Commit() error {
return t.tx.Commit()
}
func (t *Tx) Rollback() error {
return t.tx.Rollback()
}
func (t *Tx) RollbackIfNotCommit() error {
err := t.tx.Rollback()
if err != sql.ErrTxDone {
return err
}
return nil
}
这种设计也暗含了一个限制,即一个事务无法开启另 外一个事务,也就是我们的事务都是单独一个个的。
如何使用 Tx 呢? 原本的 Selector 接收的是 DB 作为参数,现在需要利用 Tx 来创建 Selector,怎么办 ?
Session 抽象
需要一个 DB 和 Tx 的公共抽象
Session 在 Web 里面有比较特殊的含义。 在 ORM 的语境下,一般代表一个上下文; 也可以理解为一种分组机制,在这个分组内所有的查询会共享一些基本的配置。
type Session interface {
getCore() core
queryContext(ctx context.Context, query string, args...any) (*sql.Rows, error)
execContext(ctx context.Context, query string, args...any) (sql.Result, error)
}
事务闭包 API
在 Beego ORM 和 GORM 里面都看到了事 务闭包 API 的设计。 所谓事务闭包 API ,即用户传入一个方法,ORM 框架会创建事务,利用事务执行该方法,然后根 据该方法的执行情况来判断需要提交还是回滚。
**Beego ORM 实例 **
核心点:
- 要判断事务内部有没有发生 panic,也就是 panicked 变量的作用
- 要判断业务代码有没有返回 error
- 发生了 panic 或者返回了 error,则回滚,否则提交
GORM 实例
GORM 和 Beego ORM 的处理逻辑都是类似 的,要判断有没有 panic,以及业务代码有没有 返回 error,两者决定是否提交
那么本文实现的事务 API 也是类似的逻辑
// DoTx 将会开启事务执行 fn。如果 fn 返回错误或者发生 panic,事务将会回滚,
// 否则提交事务
func (db *DB) DoTx(ctx context.Context,
fn func(ctx context.Context, tx *Tx) error,
opts *sql.TxOptions) (err error) {
var tx *Tx
tx, err = db.BeginTx(ctx, opts)
if err != nil {
return err
}
panicked := true
defer func() {
if panicked || err != nil {
e := tx.Rollback()
if e != nil {
err = errs.NewErrFailToRollbackTx(err, e, panicked)
}
} else {
err = tx.Commit()
}
}()
err = fn(ctx, tx)
panicked = false
return err
}
具体实现
DB 开启一个 Tx
// BeginTx 开启事务
func (db *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) {
tx, err := db.db.BeginTx(ctx, opts)
if err != nil {
return nil, err
}
return &Tx{tx: tx, db: db}, nil
}
实现 Session 抽象
func (t *Tx) getCore() core {
return t.db.core
}
func (t *Tx) queryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return t.tx.QueryContext(ctx, query, args...)
}
func (t *Tx) execContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return t.tx.ExecContext(ctx, query, args...)
}
func (db *DB) getCore() core {
return db.core
}
func (db *DB) queryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return db.db.QueryContext(ctx, query, args...)
}
func (db *DB) execContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return db.db.ExecContext(ctx, query, args...)
}
ORM Session 重构 Selector、Updator、Insertor 与 deletor
添加 core 模块, core 只是一个简单的封装,将一些 CRUD 都 需要使用的东西放到了一起。
type core struct {
r model.Registry
dialect Dialect
valCreator valuer.Creator
}
type Selector[T any] struct {
builder
table string
where []Predicate
having []Predicate
columns []Selectable
groupBy []Column
offset int
limit int
core
sess session
}
func NewSelector[T any](sess session) *Selector[T] {
c := sess.getCore()
return &Selector[T]{
core: c,
sess: sess,
builder: builder{
dialect: c.dialect,
quoter: c.dialect.quoter(),
},
}
}
type Inserter[T any] struct {
builder
values []*T
columns []string
upsert *Upsert
sess session
core
}
func NewInserter[T any](sess session) *Inserter[T] {
c := sess.getCore()
return &Inserter[T]{
core: c,
sess: sess,
builder: builder{
dialect: c.dialect,
quoter: c.dialect.quoter(),
},
}
}
type Updater[T any] struct {
builder
assigns []Assignable
val *T
where []Predicate
sess session
core
}
func NewUpdater[T any](sess session) *Updater[T] {
c := sess.getCore()
return &Updater[T]{
builder: builder{
dialect: c.dialect,
quoter: c.dialect.quoter(),
},
sess: sess,
core: c,
}
}
type Deleter[T any] struct {
builder
sess session
table string
where []Predicate
}
func NewDeleter[T any](sess session) *Deleter[T] {
c := sess.getCore()
return &Deleter[T]{
sess: sess,
builder: builder{
dialect: c.dialect,
quoter: c.dialect.quoter(),
},
}
}
RollbackIfNotCommit
Go 因为没有类似于 Java、Python 的异常捕获机制,所以经常会写出呆板代码。 前面的 DoTx 能够解决很大一部分问题,但是有些时候还是要自己控制事务。 因此我们会希望有一个方法,如果事务没有提交, 那么该方法就回滚。
func (t *Tx) RollbackIfNotCommit() error {
err := t.tx.Rollback()
if err != sql.ErrTxDone {
return err
}
return nil
}
只需要尝试回滚,如果此时事务已经被提交,或者 被回滚掉了,那么就会得到 sql.ErrTxDone 错误, 这时候我们忽略这个错误就可以。
事务扩散方案
所谓事务扩散方案,也就是在调用链里面,如果上游的方法开启了事务,那么下游的所有方法也会使用这个事务,否则 :
- 下游可以开一个新事务
- 也可以无事务运行
- 还可以报错
context 传递事务
但凡别的语言用 thread-local 的,在 Go 里面都是 用 context.Context。 核心就是创建事务的时候要检查一下 context 里面 存不存在还没有完成的事务,有就直接返回,没有 就创建一个新的。 Tx 也需要在提交或者回滚的时候将 done 设置为 true。
func (db *DB) BeginTxV2(ctx context.Context,
opts *sql.TxOptions) (context.Context, *Tx, error) {
val := ctx.Value(txKey{})
if val != nil {
tx := val.(*Tx)
if !tx.done {
return ctx, tx, nil
}
}
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return ctx, nil, err
}
ctx = context.WithValue(ctx, txKey{}, tx)
return ctx, tx, nil
}
单元测试
func TestTx_Commit(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer func() { _ = mockDB.Close() }()
db, err := OpenDB(mockDB)
if err != nil {
t.Fatal(err)
}
defer func() {
mock.ExpectClose()
_ = db.Close()
}()
// 事务正常提交
mock.ExpectBegin()
mock.ExpectCommit()
tx, err := db.BeginTx(context.Background(), &sql.TxOptions{})
assert.Nil(t, err)
err = tx.Commit()
assert.Nil(t, err)
}
func TestTx_Rollback(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer func() { _ = mockDB.Close() }()
db, err := OpenDB(mockDB)
if err != nil {
t.Fatal(err)
}
// 事务回滚
mock.ExpectBegin()
mock.ExpectRollback()
tx, err := db.BeginTx(context.Background(), &sql.TxOptions{})
assert.Nil(t, err)
err = tx.Rollback()
assert.Nil(t, err)
}
总结
- 什么是事务扩散?在 Go 里面怎么解决?其实本质就是上下文里面有事务就用事务,没有事务就开新事 务。Go 里面要解决的话只能依赖于 context.Context,基本上在别的语言里面用 thread-local 解决 的,到 Go 里面都是用 context.Context ;
- 事务扩散中,如果没有开启事务应该怎么办?看业务,可以选择报错,可以选择开启新事务,也可以无事务运行 ;
- 事务重复提交会怎样?在 ORM 层面上,有些 ORM 会维护一个标记位,标记一个事务有没有被提交。 即便没有这个标记位,数据库也会返回错误 ;
- Go 里面实现一个事务闭包要考虑一些什么问题?如何实现?主要是考虑 panic 的问题,而后要在 panic 的时候,以及业务代码返回 error 的时候,回滚事务;