(エヴァは見たことないです)
Go でトランザクションをネストさせたくなったことはありませんか。
私はユニットテストでテストケースごとにトランザクションを張り 終わったときにロールバックすることでテーブルを常に空の状態にしようと考えましたが、 実際には保存処理の中にトランザクションが存在してハマりました。
いろいろ悪戦苦闘しましたがなんとかできたので共有します。
- warning
- 今回はコードの見やすさを優先してエラーハンドリングをしていません。
- 実務のコードでは忘れずに行いましょう。
準備
以下の docker-compose.yaml を使って環境を準備します。
FROM golang:1.13-stretch
RUN set -x; \
apt-get update -y &&\
apt-get install -y mysql-client
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:
スキーマは以下です。
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 をするだけのプログラムを書いてみました。
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()
を呼べばいい感じにネストされるんじゃね?と思いましたか?
そんなわけはありません。 普通に別のトランザクションになるのでロールバックされるのは外側のトランザクションだけです。
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 を除去)
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が異なるだけで同じトランザクションに属するため分離されない。
- これは SAVEPOINT をあまり意識せず
これを使って次のように実装してみました。
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()によってntx5とntx6のセーブポイントを解放ntx6でhareを追加ntx6.Rollback()を実行するがntx6は既に存在しないためロールバックされない- エラーハンドリングをしていないため表示されない
alpaca,weasel,ostrich,hareを表示ntxまでロールバックされるため、追加された上記レコードはすべて破棄
だいたいこんなかんじ。
- info
- このプログラムでは
Begin()によって新規のトランザクションが数珠つなぎになっているように見えますが、 SQL上では単なる SAVEPOINT という点に過ぎず、前後関係以外の関連はありません。 - このような理由から
ntx4.Commit()で解放された後に作られたntx5は破棄されませんし、ntx6.Execは点に紐付いているわけではないためntx6というセーブポイントが既に破棄されていても実行できます。
- このプログラムでは
ユニットテスト
私の目的であったユニットテストでDBがリセットされるか確認してみます。
target パッケージに対象関数を置きました。
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()
}
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
を作りました。
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)と同じことをしてみます。
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