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

2020-06-01

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

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

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

目次

警告

今回はコードの見やすさを優先してエラーハンドリングをしていません。

実務のコードでは忘れずに行いましょう。

準備 preparation

以下の 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    |                |
+-------+--------------+------+-----+---------+----------------+

普通のトランザクション normal-transaction

まずは 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;" # レコードなし

いい感じです。

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

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

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

しかし 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    |
+----+--------+

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

対応 corresponding

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

というわけで、生SQLで SAVEPOINT を書く方法しかなさそうです..

と思ったら https://github.com/golang/go/issues/7898#issuecomment-382051346SAVEPOINT を使ったコードを載せてくれてる方がいました。あなたが神か。ちなみにこの Issue はまだ Open です。

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

transaction/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()
}

警告

これは SAVEPOINT をあまり意識せず sql.Tx と同じようなインタフェースで操作できるようになっていますが、 実際の動きまで Tx と全く同じはずはありません。

  • Rollback() は 作成した SAVEPOINT の状態に戻す

  • Commit() は 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も時間によって進展するので時間軸のようなものですが)

blockdiag ntxntx2ntx3ntx4ntx5ntx6INSERT alpacaINSERT pheasantINSERT reindeerINSERT moleINSERT weaselINSERT ostrichINSERT harentx.Begin()ntx2.Begin()ntx3.Begin()ntx4.Commit()ntx2.Rollback()ntx4.Begin()ntx5.Begin()ntx5.Commit()ntx6.Rollback()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 までロールバックされるため、追加された上記レコードはすべて破棄

だいたいこんなかんじ。

備考

このプログラムでは Begin() によって新規のトランザクションが数珠つなぎになっているように見えますが、 SQL上では単なる SAVEPOINT という に過ぎず、前後関係以外の関連はありません。

このような理由から ntx4.Commit() で解放された後に作られた ntx5 は破棄されませんし、 ntx6.Exec は点に紐付いているわけではないため ntx6 というセーブポイントが既に破棄されていても実行できます。

ユニットテスト unittest

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

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

テスト対象関数

テストコード

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()
}

target/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 を作りました。

transaction/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を使ってもだいたい同じようにかけるのですね。