2020-06-01

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

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

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() によって ntx5ntx6 のセーブポイントを解放
  • ntx6hare を追加
  • 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 - pkg.go.devhttps://pkg.go.dev/github.com/go-gorp/gorp