2020-06-01

Golang でトランザクションをネストさせるたった一つの冴えたやり方

(エヴァは見たことないです)

Go でトランザクションをネストさせたくなったことはありませんか。

私はユニットテストでテストケースごとにトランザクションを張り 終わったときにロールバックすることでテーブルを常に空の状態にしようと考えましたが、 実際には保存処理の中にトランザクションが存在してハマりました。

いろいろ悪戦苦闘しましたがなんとかできたので共有します。

warning
  • 今回はコードの見やすさを優先してエラーハンドリングをしていません。
  • 実務のコードでは忘れずに行いましょう。

準備

以下の docker-compose.yaml を使って環境を準備します。

Dockerfile
FROM golang:1.13-stretch

RUN set -x; \
  apt-get update -y &&\
  apt-get install -y mysql-client

docker-compose.yaml
version: "3.7"
services:
  app:
    container_name: go-app
    volumes:
      - ./:/root/
    working_dir: /root/
    build: .
    tty: true
    networks:
      - go-sql-network

  mysql:
    image: mariadb:latest
    container_name: go-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: db
      MYSQL_USER: usr
      MYSQL_PASSWORD: pw
    volumes:
      - ./data/mysql/:/var/lib/
    expose:
      - "3306"
    networks:
      - go-sql-network
    ports:
      - "3306:3306"


networks:
  go-sql-network:


スキーマは以下です。

schema.sql
CREATE TABLE animals (
     id MEDIUMINT NOT NULL AUTO_INCREMENT,
     name CHAR(30) NOT NULL,
     PRIMARY KEY (id)
);

$ docker exec -it go-app /bin/bash root@69dc4d712065:~# mysql -h mysql -uusr -ppw db < schema.sql root@69dc4d712065:~# mysql -h mysql -uusr -ppw db -e "desc animals" +-------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-------+--------------+------+-----+---------+----------------+ | id | mediumint(9) | NO | PRI | NULL | auto_increment | | name | char(30) | NO | | NULL | | +-------+--------------+------+-----+---------+----------------+

普通のトランザクション

まずは INSERT した結果を ROLLBACK をするだけのプログラムを書いてみました。

insert_and_rollback.go
package main

import (
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"
)

func main() {
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql:3306)/db")
	tx, _ := db.Begin()
	defer tx.Rollback()
	tx.Exec("INSERT animals (name) VALUES ('alpaca'),('dog'),('cat') ;")

	rows, _ := tx.Query("SELECT id, name FROM animals")
	for rows.Next() {
		var id int
		var name string
		rows.Scan(&id, &name)
		fmt.Println(id, name)
	}
}

root@69dc4d712065:~# go run insert_and_rollback.go 1 alpaca 2 dog 3 cat root@69dc4d712065:~# go run insert_and_rollback.go 4 alpaca 5 dog 6 cat root@69dc4d712065:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM animals;" # レコードなし

いい感じです。

トランザクションをネストしたい

さて、ここからが本番です。

そもそもトランザクションにネストという概念はありません。

しかし SAVEPOINT を用いて擬似的にネストを実現する方法はあります。

じゃあ Golang ではどのように SAVEPOINT を扱っているのでしょうか。

NGパターン

tx.Begin() を呼ぶ?

違います。現時点(2020/03)で *sql.Tx は Begin メソッドをもっていません。

じゃあトランザクションの中で db.Begin() を呼べばいい感じにネストされるんじゃね?と思いましたか?

そんなわけはありません。 普通に別のトランザクションになるのでロールバックされるのは外側のトランザクションだけです。

insert_and_rollback2.go
package main

import (
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"
)

func main() {
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql:3306)/db")
	tx, _ := db.Begin()
	defer tx.Rollback()

	txNested, _ := db.Begin()
	txNested.Exec("INSERT animals (name) VALUES ('alpaca'),('dog'),('cat') ;")
	txNested.Commit()

	rows, _ := tx.Query("SELECT id, name FROM animals")
	for rows.Next() {
		var id int
		var name string
		rows.Scan(&id, &name)
		fmt.Println(id, name)
	}
}

root@69dc4d712065:~# go run insert_and_rollback2.go 7 alpaca 8 dog 9 cat root@69dc4d712065:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM animals;" +----+--------+ | id | name | +----+--------+ | 7 | alpaca | | 8 | dog | | 9 | cat | +----+--------+

当然ですがレコードが残っています。

対応

残念ながら goの標準(準標準)ライブラリに SAVEPOINT をうまく扱ってくれる機能は現状で存在しないようです。

というわけで、生SQLで SAVEPOINT を書く方法しかなさそうです.. と思ったら SAVEPOINT を使ったコードを載せてくれてる方がいました。あなたが神か。 https://github.com/golang/go/issues/7898#issuecomment-382051346

ちなみにこの Issue はまだ Open です。

このコードをほぼパクって、以下のように書きました(BEGIN を除去)

transaction.go
package transaction

import (
	"database/sql"
	"strconv"
)

type NestableTx struct {
	*sql.Tx

	savePoint int
	next      *NestableTx
	resolved  bool
}

func (tx *NestableTx) Begin() (*NestableTx, error) {
	tx.next = &NestableTx{
		Tx:        tx.Tx,
		savePoint: tx.savePoint + 1,
	}

	_, err := tx.Exec("SAVEPOINT SP" + strconv.Itoa(tx.next.savePoint))

	if err != nil {
		return nil, err
	}

	return tx.next, nil
}

func (tx *NestableTx) Rollback() error {
	tx.resolved = true

	if tx.savePoint > 0 {
		_, err := tx.Exec("ROLLBACK TO SAVEPOINT SP" + strconv.Itoa(tx.savePoint))
		return err
	}

	return tx.Tx.Rollback()
}

func (tx *NestableTx) Commit() error {
	if tx.next != nil && !tx.next.resolved {
		err := tx.next.Commit()
		if err != nil {
			return err
		}
	}

	tx.resolved = true

	if tx.savePoint > 0 {
		_, err := tx.Exec("RELEASE SAVEPOINT SP" + strconv.Itoa(tx.savePoint))

		return err
	}

	return tx.Tx.Commit()
}

warning
  • これは SAVEPOINT をあまり意識せず sql.Tx と同じようなインタフェースで操作できるようになっていますが、 実際の動きまで Tx と全く同じはずはありません。
    • Rollback() は 作成した SAVEPOINT の状態に戻す
    • Commit() は SAVEPOINT の解放
      • 指定したセーブポイントの後に設定されたセーブポイントは全て破棄 RELEASE SAVEPOINT
    • 同じ sql.Tx から作られたすべての ntx はSAVEPOINTが異なるだけで同じトランザクションに属するため分離されない。

これを使って次のように実装してみました。

insert_and_rollback3.go
package main

import (
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"

	"nested-transaction/transaction"
)

func main() {
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql:3306)/db")
	tx, _ := db.Begin()
	ntx := &transaction.NestableTx{Tx: tx}
	defer ntx.Rollback()

	ntx.Exec("INSERT animals (name) VALUES (?);", "alpaca")

	ntx2, _ := ntx.Begin()
	ntx2.Exec("INSERT animals (name) VALUES (?);", "pheasant")

	ntx3, _ := ntx2.Begin()
	ntx3.Exec("INSERT animals (name) VALUES (?);", "reindeer")

	ntx4, _ := ntx3.Begin()
	ntx4.Exec("INSERT animals (name) VALUES (?);", "mole")
	ntx4.Commit()
	ntx2.Rollback()

	ntx5, _ := ntx4.Begin()
	ntx5.Exec("INSERT animals (name) VALUES (?);", "weasel")

	ntx6, _ := ntx5.Begin()
	ntx6.Exec("INSERT animals (name) VALUES (?);", "ostrich")
	ntx5.Commit()
	ntx6.Exec("INSERT animals (name) VALUES (?);", "hare")
	ntx6.Rollback()
	show(ntx)
}

func show(ntx *transaction.NestableTx) {
	rows, _ := ntx.Query("SELECT id, name FROM animals")
	for rows.Next() {
		var id int
		var name string
		rows.Scan(&id, &name)
		fmt.Println(id, name)
	}
}

# 一旦テーブルをリセット root@69dc4d712065:~# mysql -h mysql -uusr -ppw db -e "DELETE FROM animals;" root@69dc4d712065:~# go run insert_and_rollback3.go 10 alpaca 14 weasel 15 ostrich 16 hare root@69dc4d712065:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM animals;" # レコードなし

解説

これをイメージにすると以下のようになります。

縦軸が時間軸、横軸が SAVEPOINT です(SAVEPOINTも時間によって進展するので時間軸のようなものですが)

sequenceDiagram participant ntx Note right of ntx: INSERT alpaca ntx->>ntx2: ntx.Begin() participant ntx2 Note right of ntx2: INSERT pheasant ntx2->>ntx3: ntx2.Begin() participant ntx3 Note right of ntx3: INSERT reindeer ntx3->>ntx4: ntx3.Begin() participant ntx4 Note right of ntx4: INSERT mole ntx4->>ntx4: ntx4.Commit() ntx4->>ntx2: ntx2.Rollback() ntx4->>ntx5: ntx4.Begin() participant ntx5 Note right of ntx5: INSERT weasel ntx5->>ntx6: ntx5.Begin() participant ntx6 ntx6->>ntx5: ntx5.Commit() Note right of ntx6: INSERT ostrich ntx6->>ntx6: ntx6.Rollback() Note right of ntx6: INSERT hare ntx6->>ntx: ntx.Rollback()

ステップごとに流れを追ってみましょう。

  • ntx (基底のトランザクション) で alpaca を追加
  • ntx2 (ntx から発行したセーブポイント) で pheasant を追加
  • ntx3 (ntx2 の後に発行したセーブポイント) で reindeer を追加
  • ntx4 (ntx3 の後に発行したセーブポイント) で mole を追加
    • ntx4.Commit() によって ntx4 のセーブポイントを解放
    • ntx2.Rollback() によって ntx2 以降に追加された pheasant, reindeer, mole が破棄
  • ntx5 (ntx4 の後に発行したセーブポイント) で weasel を追加
  • ntx6 (ntx5 の後に発行したセーブポイント) で ostrich を追加
  • ntx5.Commit() によって ntx5ntx6 のセーブポイントを解放
  • ntx6hare を追加
  • ntx6.Rollback() を実行するが ntx6 は既に存在しないためロールバックされない
    • エラーハンドリングをしていないため表示されない
  • alpaca, weasel, ostrich, hare を表示
  • ntx までロールバックされるため、追加された上記レコードはすべて破棄

だいたいこんなかんじ。

info
  • このプログラムでは Begin() によって新規のトランザクションが数珠つなぎになっているように見えますが、 SQL上では単なる SAVEPOINT という に過ぎず、前後関係以外の関連はありません。
  • このような理由から ntx4.Commit() で解放された後に作られた ntx5 は破棄されませんし、 ntx6.Exec は点に紐付いているわけではないため ntx6 というセーブポイントが既に破棄されていても実行できます。

ユニットテスト

私の目的であったユニットテストでDBがリセットされるか確認してみます。

target パッケージに対象関数を置きました。

multiple_insert.go
package target

import (
	"nested-transaction/transaction"

	_ "github.com/go-sql-driver/mysql"
)

func multipleInsert(ntx *transaction.NestableTx, animals []string) {
	ntx, _ = ntx.Begin()
	for _, a := range animals {
		ntx.Exec("INSERT animals (name) VALUES (?);", a)
	}
	ntx.Commit()
}

multiple_insert_test.go
package target

import (
	"database/sql"
	"testing"

	"nested-transaction/transaction"

	_ "github.com/go-sql-driver/mysql"
	"github.com/google/go-cmp/cmp"
)

func TestMultipleInsert(t *testing.T) {
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql:3306)/db")
	defer db.Close()
	tx, _ := db.Begin()
	ntx := &transaction.NestableTx{Tx: *tx}
	defer ntx.Rollback()

	expected := []string{"alpaca", "dog", "cat"}
	multipleInsert(ntx, expected)
	actual := getAnimals(ntx)
	if r := cmp.Diff(expected, actual); r != "" {
		t.Errorf("expected: %#v, actual: %#v", expected, actual)
	}
}

func getAnimals(ntx *transaction.NestableTx) []string {
	animals := []string{}
	rows, _ := ntx.Query("SELECT name FROM animals")
	for rows.Next() {
		var name string
		rows.Scan(&name)
		animals = append(animals, name)
	}
	return animals
}

root@69dc4d712065:~/target# go test -run TestMultipleInsert PASS ok nested-transaction/target 0.036s root@69dc4d712065:~/target# mysql -h mysql -uusr -ppw db -e "SELECT * FROM animals;" # レコードなし

無事に通りましたね。実行後のレコードも 0件 なので無事 Rollback されたようです。

NesteableTx を引数とする必要がありますが、実引数を変えたくない場合は引数をインタフェースにすることを検討しましょう。

最後に、タイトルでたった一つと書きましたがまぁそんなわけはなく他にも良いやり方はあると思うので 自分なりの良いやり方を探してみてください。

標準でサポートされるまでの一時しのぎではありますが、それまではこの方法で乗り切っていこうと思います。

gorp

同じようなことを gorp という ORM でもやりたくなったので NestableTx を参考に NestableGorpTx を作りました。

gorp.go
package transaction

import (
	"strconv"

	"github.com/go-gorp/gorp"
)

type NestableGorpTx struct {
	*gorp.Transaction

	savePoint int
	next      *NestableGorpTx
	resolved  bool
}

func (tx *NestableGorpTx) Begin() (*NestableGorpTx, error) {
	tx.next = &NestableGorpTx{
		Transaction: tx.Transaction,
		savePoint:   tx.savePoint + 1,
	}
	if err := tx.Savepoint("SP" + strconv.Itoa(tx.next.savePoint)); err != nil {
		return nil, err
	}
	return tx.next, nil
}

func (tx *NestableGorpTx) Rollback() error {
	tx.resolved = true
	if tx.savePoint > 0 {
		return tx.RollbackToSavepoint("SP" + strconv.Itoa(tx.savePoint))
	}
	return tx.Transaction.Rollback()
}

func (tx *NestableGorpTx) Commit() error {
	if tx.next != nil && !tx.next.resolved {
		err := tx.next.Commit()
		if err != nil {
			return err
		}
	}
	tx.resolved = true

	if tx.savePoint > 0 {
		return tx.ReleaseSavepoint("SP" + strconv.Itoa(tx.savePoint))
	}
	return tx.Transaction.Commit()
}

gorp のトランザクションでは sql.Tx を直接扱うことはなく dbmap.Begin() で生成した Transaction を使うことになるので gorp.Transaction を構造体に埋め込んでいるのと、 gorp.Transaction はセーブポイントを扱うためのメソッドを持っているためそれを使ってセーブポイントの生成、解放、破棄を行っています。

これを使って先程のプログラム (insert_and_rollback3.go)と同じことをしてみます。

insert_and_rollback4.go
package main

import (
	"database/sql"
	"fmt"

	"github.com/go-gorp/gorp"
	_ "github.com/go-sql-driver/mysql"

	"nested-transaction/transaction"
)

func main() {
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql:3306)/db")
	dbmap := &gorp.DbMap{Db: db, Dialect: gorp.MySQLDialect{Engine: "InnoDB", Encoding: "utf8mb4"}}
	defer dbmap.Db.Close()
	tx, _ := dbmap.Begin()
	ntx := &transaction.NestableGorpTx{Transaction: tx}
	defer ntx.Rollback()

	ntx.Exec("INSERT animals (name) VALUES (?);", "alpaca")

	ntx2, _ := ntx.Begin()
	ntx2.Exec("INSERT animals (name) VALUES (?);", "pheasant")

	ntx3, _ := ntx2.Begin()
	ntx3.Exec("INSERT animals (name) VALUES (?);", "reindeer")

	ntx4, _ := ntx3.Begin()
	ntx4.Exec("INSERT animals (name) VALUES (?);", "mole")
	ntx4.Commit()
	ntx2.Rollback()

	ntx5, _ := ntx4.Begin()
	ntx5.Exec("INSERT animals (name) VALUES (?);", "weasel")

	ntx6, _ := ntx5.Begin()
	ntx6.Exec("INSERT animals (name) VALUES (?);", "ostrich")
	ntx5.Commit()
	ntx6.Exec("INSERT animals (name) VALUES (?);", "hare")
	ntx6.Rollback()
	show(ntx)
}

func show(exec gorp.SqlExecutor) {
	animals := []struct {
		ID   int
		Name string
	}{}

	exec.Select(&animals, "SELECT id, name FROM animals") // 今回はエラーは捨てる
	for _, row := range animals {
		fmt.Println(row.ID, row.Name)
	}
}

root@69dc4d712065:~# go run insert_and_rollback4.go 14 alpaca 18 weasel 19 ostrich 20 hare

違うのは以下の2点だけであとはほとんど同じです。

  • show 関数で gorp.SqlExecutor インタフェースを引数にする
    • コネクションを抽象化することでユニットテストがしやすくなる
      • 通常時は gorp.Dbmap を渡し、テスト時は gorp.Transaction を渡すことができる
  • row.Scan ではなく exec.Select で結果を読み出す

ORMを使ってもだいたい同じようにかけるのですね。

https://pkg.go.dev/github.com/go-gorp/gorp