TL;DR

ginでの各リクエストを1セッションで管理しつつ、トランザクションも管理したい。

  1. ginのmiddlewareで、gin.Contextにgorm.WithContextを用いてGormのセッションを持たせる。
  2. ginの各HandlerFuncの中では、gin.Context内のGormセッションからDBへアクセスする。

モチベーション

Gormはv2になり色々と変わった。 Gorm 2.0 Release Note

色々な記事が書かれているがTransactoinに関してあまり書かれていない。

下記疑問を払拭して正しい使い方を自分なりに確かめたかったので試した。

  • なんとなく動くがこれで正しいのか?
  • ドキュメントを読んでいてSkipDefaultTransaction:trueだと30%もパフォーマンスが上がるというがその設定はtrueにしていいいのか?
  • っていうか、DefaultTransactionって何?ロールバックの単位とかどうやって指定するの?

確認&検証

検証時に作成したソースはgithub.com/lunarxlark/gorm2-tx

Context

Gorm 2.0 #Context

GormはContextサポートを提供し、それを使いたかったらWithContext使ってくれ。
また、Sessionには単セッションモードと継続セッションモードがある。
普通は、継続セッションモードを使って、複数オペレーションをまとめるよ。

…GormのSessionってどんなことできるの?

Session

Gorm 2.0 #Session

Gormは`Session`メソッドをを通して、新しいセッションを提供するよ。
新しいセッションを作る場合、設定がたくさんあるよ。DryRunやLoggerとかね。

…新しいセッションを一々作るのは望んでない。

Contextサポートしてくれるってことなのでgin.Contextへ埋め込む時にセッションを作成してそれを使い回したい。

gorm.WithContextのreturnはdb.Sessionとなっている(下記はgormのソースから抜粋)ので、gin.Contextへ埋め込むだけで新たにセッションを作る必要はない。

1
2
3
4
// WithContext change current instance db's context to ctx
func (db *DB) WithContext(ctx context.Context) *DB {
	return db.Session(&Session{Context: ctx})
}

ここまでで下記みたいな感じになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func main() {
  //...
  r := gin.New()
	r.Use(DBSession())
  //...
}

func DBSession() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Set("DB", infra.RDB.WithContext(c))
		c.Next()
	}
}

var RDB *gorm.DB
var dsnWriter = "host=writer user=postgres port=5432 dbname=testDB password=pass sslmode=disable"
var dsnReader = "host=reader user=postgres port=5432 dbname=testDB password=pass sslmode=disable"

func DbOpen() error {
	var err error
	RDB, err = gorm.Open(
		postgres.New(postgres.Config{
			DSN: dsnWriter,
		}),
	)
	if err != nil {
		return err
	}

	RDB.Use(dbresolver.Register(dbresolver.Config{
		Sources:  []gorm.Dialector{postgres.Open(dsnWriter)},
		Replicas: []gorm.Dialector{postgres.Open(dsnReader)},
	}))

	return nil
}

Transaction

Gorm 2.0 #Transaction

gorm.Sessionの設定にはSkipDefaultTransactionって項目あるけど、特にトランザクションの関数でオペレーションを囲わない場合、これはgorm.Session単位でロールバックしてくれるのか?

検証のため、 下記テーブルを用意してそれぞれのテーブルに対してInsert文を実行する。 後からInsertされるUserテーブルのInsertは重い処理にして、リクエストを中断によりロールバックされるか検証する。

  1. Todoテーブル
  2. Userテーブル
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
type Todo struct {
	ID   string `gorm:"primaryKey;column:id"`
	Name string `gorm:"column:name"`
}

type User struct {
	ID   string `gorm:"primaryKey;column:id"`
	Name string `gorm:"column:name"`
}

func main() {
  //...
	r.GET("/", func(c *gin.Context) {
		var t []Todo
		for i := 1; i < 500; i++ {
			t = append(t, Todo{
				ID: strconv.Itoa(i),
				Name: "todo name",
			})
		}

		var r []User
		for i := 1; i < 5000000; i++ {
			r = append(r, User{
				ID: strconv.Itoa(i),
				User: "山田太郎",
			})
		}

		session := c.Value("DB").(*gorm.DB)
		fmt.Println("### insert TODO")
		resultTodo := session.Table("todo").CreateInBatches(t, 100)
		if resultTodo.Error != nil {
			fmt.Println(resultTodo.Error)
		}

		fmt.Println("### insert User")
		resultUser := session.Table("user").CreateInBatches(r, 10000)
		if resultUser.Error != nil {
			fmt.Println(resultUser.Error)
		}

		c.JSON(http.StatusOK, gin.H{"message": "success"})
	})
  //...
}

検証の結果、これではロールバックされない。TodoテーブルへのInsertが成功してしまっている。

下記のように、セッションを用いてトランザクションを開始すると期待通りに両テーブルのレコード数は0となる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
		session := c.Value("DB").(*gorm.DB)
		if err := session.Transaction(func(tx *gorm.DB) error {
			fmt.Println("### insert title_type_tbl")
			txTitle := tx.Table("title_type_tbl").CreateInBatches(t, 100)
			if txTitle.Error != nil {
				return txTitle.Error
			}

			fmt.Println("### insert rental_type_tbl")
			txRentalType := tx.Table("rental_type_tbl").CreateInBatches(r, 10000)
			if txRentalType.Error != nil {
				return txRentalType.Error
			}
			return nil
		}); err != nil {
			fmt.Println("failed in transaction")
		}

では、gorm.Sessionやgorm.ConfigにあるSkipDefaultTransactionとはどの範囲のことを言っているのか?

Gormの書き込みオペレーション(create/update/delete)では、データの一貫性を保証するため内部ではトランザクション内で実行される。

実際、Createの中身を見ていくとCreateBatchSizeを指定した際に実行されるCreateInBatchesでは

  • SkipDefaultTransaction:falseなら、トランザクション内でバッチ処理
  • SkeipDefaultTransaction:trueなら、Session内でバッチ処理

となる。

結論

gormのCreate/Update/Delete内でしかトランザクション管理しなくていい(例:1リクエスト1テーブル更新のみ)みたいな場合のみ、SkipDefaultTransactionをfalse。 それ以外(例:1リクエストで2テーブル更新)の場合、Transactionで囲ってあげる必要があり、SkipDefaultTransaction:trueにしてよさそう。

…(本当はDBResolverの動作検証をしたくてpostgreSQLのクラスター組んでいたらこの検証してた…。次はDBResolverの記事かけたらいいなと思います。)

(余談)

gormのドキュメントのConfigにはあったりなかったりだが、Pluginという項目がある。 gorm.io/plugin/prometheusを参考にしてね、と書かれている。

gorm.PluginはinterfaceでName()とInitialize(*gorm.DB)を満たせば良い。 prometheusのpluginを見にいくとメトリクスコレクターというのがtickで数秒間隔でメトリクスを取得しているっぽいことがわかった。 おしまい。