(エヴァは見たことないです)
Go でトランザクションをネストさせたくなったことはありませんか。
私はユニットテストでテストケースごとにトランザクションを張り 終わったときにロールバックすることでテーブルを常に空の状態にしようと考えましたが、 実際には保存処理の中にトランザクションが存在してハマりました。
いろいろ悪戦苦闘しましたがなんとかできたので共有します。
- warning
- 今回はコードの見やすさを優先してエラーハンドリングをしていません。
- 実務のコードでは忘れずに行いましょう。
準備
以下の docker-compose.yaml を使って環境を準備します。
スキーマは以下です。
$ 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 をするだけのプログラムを書いてみました。
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()
を呼べばいい感じにネストされるんじゃね?と思いましたか?
そんなわけはありません。 普通に別のトランザクションになるのでロールバックされるのは外側のトランザクションだけです。
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
を使ったコードを載せてくれてる方がいました。あなたが神か。
database/sql: nested transaction or save point support · Issue #7898 · golang/goIt might be useful to consider supporting nested transactions when a particular driver/DB combo is able to support that. #Go 1.4+https://github.com/golang/go/issues/7898
ちなみにこの Issue はまだ Open です。
このコードをほぼパクって、以下のように書きました(BEGIN
を除去)
- warning
これは SAVEPOINT をあまり意識せず
sql.Tx
と同じようなインタフェースで操作できるようになっていますが、 実際の動きまでTx
と全く同じはずはありません。Rollback()
は 作成した SAVEPOINT の状態に戻すCommit()
は SAVEPOINT の解放- 指定したセーブポイントの後に設定されたセーブポイントは全て破棄 RELEASE SAVEPOINT
- 同じ
sql.Tx
から作られたすべてのntx
はSAVEPOINTが異なるだけで同じトランザクションに属するため分離されない。
これを使って次のように実装してみました。
# 一旦テーブルをリセット 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
パッケージに対象関数を置きました。
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 のトランザクションでは sql.Tx
を直接扱うことはなく
dbmap.Begin()
で生成した Transaction
を使うことになるので gorp.Transaction
を構造体に埋め込んでいるのと、
gorp.Transaction
はセーブポイントを扱うためのメソッドを持っているためそれを使ってセーブポイントの生成、解放、破棄を行っています。
これを使って先程のプログラム (insert_and_rollback3.go)と同じことをしてみます。
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を使ってもだいたい同じようにかけるのですね。
gorp package - github.com/go-gorp/gorp - Go Packageshttps://pkg.go.dev/github.com/go-gorp/gorp