トランザクションのネストの使い方まとめた（初心者向け）

トランザクションのネストについてまとめてみました
どう記述したらネストができるの？
ロールバックした時の挙動は？
などなどまとめてみました
自分がよく使うMySQLとRails(ActiveRecord)について記載します。他のDBやフレームワークでは多分話が変わりますのでご注意ください

# 前提

## ネストしたトランザクションの挙動

ネストしたトランザクションって、正確な挙動がこうあるべきという決まりがあるのかどうかは筆者はよく知りません
ここでは、以下のような挙動を満たすことを目的にします

- トランザクションの内部に、もう一つトランザクションを貼る
- 内側のトランザクションがロールバックした場合、外側のトランザクションには影響を与えない
- 外側のトランザクションがロールバックした場合、内側のトランザクションもロールバックする
  - 内側だけコミットされてしまうと、外側のトランザクションから見ると一貫性が破綻することになるため
- つまり「内側だけコミットする」はナシ。「内側だけロールバックする」はアリ

## テストデータ

この記事では、以下のような `users` テーブルを使用して実験しています

```mysql
mysql> show create table users;
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------+
| users | CREATE TABLE `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------+
```

# MySQL におけるトランザクションのネスト

## BEGINを2回書く（ダメな方法）

トランザクションの開始は `BEGIN` です。
じゃあ `BEGIN` の中でもう一回 `BEGIN` を書いてみたらどうなる？

### 正常系

まずは、ネストした上で正常にコミットさせてみます

```mysql
BEGIN;
INSERT INTO users VALUES(1, 'before-nest');
  BEGIN;
  INSERT INTO users VALUES(2, 'in-nest');
  COMMIT;
INSERT INTO users VALUES(3, 'after-nest');
COMMIT;

select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  2 | in-nest     |
|  3 | after-nest  |
+----+-------------+
```
（みやすいようにインデントしてますが本来は不要です）

問題ないようです
（※が、**実は問題があります**。後述）

### ネストの中でロールバックする

```mysql
BEGIN;
INSERT INTO users VALUES(1, 'before-nest');
  BEGIN;
  INSERT INTO users VALUES(2, 'in-nest');
  ROLLBACK;  # <===ココ
INSERT INTO users VALUES(3, 'after-nest');
COMMIT;

select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  3 | after-nest  |
+----+-------------+
```

ネスト内の INSERT 文だけロールバックされています
どうやら正しく動いているようです
（※が、 **これも実は問題あり** なのです。後述）

### ネストの後でロールバックする

```mysql
BEGIN;
INSERT INTO users VALUES(1, 'before-nest');
  BEGIN;
  INSERT INTO users VALUES(2, 'in-nest');
  COMMIT;
INSERT INTO users VALUES(3, 'after-nest');
ROLLBACK;  # <=== ココ

select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  2 | in-nest     |
|  3 | after-nest  |
+----+-------------+
```

**おかしい！ロールバックされてない**
外側のトランザクションでロールバックが起きたわけですから、内側(`in-nest`)も含めて全部ロールバックされていないとおかしいわけです

### BEGIN はネストできない

MySQL では、トランザクションはネストすることができないのです
実は、2度目の `BEGIN` が実行された時、**現在のトランザクションがコミットされ、新しいトランザクションが開始**しているんです。

このことは公式ドキュメントにも書いてあります。
https://dev.mysql.com/doc/refman/5.6/ja/implicit-commit.html
> トランザクションをネストすることはできません。これは、START TRANSACTION ステートメントまたはそのシノニムのいずれかを発行するときに、現在のすべてのトランザクションに対して実行される暗黙的なコミットの結果です。

つまり、2度目の `BEGIN` の時点で、 `before-nest` のインサート文が**コミットされます**。ネストの `COMMIT;` の時点で `in-nest` もコミットされますよね。そして `after-nest` は、**トランザクション外**で実行されているわけです。だからその後 `ROLLBACK;` が実行されようがロールバックされません。

`COMMIT;` `ROLLBACK;` って、トランザクション貼ってない時に実行しても特にエラーとか起こさないんですねえ。

ということから、トランザクションネストしたい時に `BEGIN` は使っちゃダメということがわかりました

## SAVEPOINT を使う（正しい方法）

というわけで、こういう時は `SAVEPOINT` 命令を使います。
外側のトランザクションは `BEGIN` のままでよく、内側のトランザクションは `SAVEPOINT` と書きます。

書き方は以下のように対応しています

|BEGIN文|SAVEPOINT文|
|---|---|
|BEGIN|SAVEPOINT hoge|
|COMMIT|RELEASE SAVEPOINT hoge|
|ROLLBACK|ROLLBACK TO SAVEPOINT hoge|

SAVEPOINT はいくつも同時に貼ることができるため、必ず命令文の後に名前を指定する形になります

### 正常系

```mysql
BEGIN;
INSERT INTO users VALUES(1, 'before-nest');
  SAVEPOINT nest; # <===ココ
  INSERT INTO users VALUES(2, 'in-nest');
  RELEASE SAVEPOINT nest;  # <===ココ
INSERT INTO users VALUES(3, 'after-nest');
COMMIT;

select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  2 | in-nest     |
|  3 | after-nest  |
+----+-------------+
```

ちゃんとインサートされていますね

### ネストの中でロールバックする

```mysql
BEGIN;
INSERT INTO users VALUES(1, 'before-nest');
  SAVEPOINT nest;
  INSERT INTO users VALUES(2, 'in-nest');
  ROLLBACK TO SAVEPOINT nest;  # <===ココ
INSERT INTO users VALUES(3, 'after-nest');
COMMIT;

select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  3 | after-nest  |
+----+-------------+
```

内側だけロールバックしていますね

### ネストのあとでロールバックする

```mysql
BEGIN;
INSERT INTO users VALUES(1, 'before-nest');
  SAVEPOINT nest;
  INSERT INTO users VALUES(2, 'in-nest');
  RELEASE SAVEPOINT nest;
INSERT INTO users VALUES(3, 'after-nest');
ROLLBACK;  # <===ココ

select * from users;
Empty set (0.00 sec)
```

全部ロールバックしています！

`SAVEPOINT` を使うと、ネストトランザクションが正しく動作することが確認できました。


# ActiveRecord におけるトランザクションのネスト

さて今度は ActiveRecord でトランザクションを記述したらどういうクエリが発行されるかの確認です
DBは引き続きMySQLです

## 何も考えず `.transaction` してみる（ダメな方法）

### 正常系

トランザクションをネストさせたコードを書きます
それによって実行されたクエリとDBの中身を続けて書いています。

```rb
User.transaction do
  User.create!(id: 1, name: 'before-nest')
  User.transaction do
    User.create!(id: 2, name: 'in-nest')
  end
  User.create!(id: 3, name: 'after-nest')
end
```

```mysql
BEGIN
INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest')
INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest')
INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest')
COMMIT

+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  2 | in-nest     |
|  3 | after-nest  |
+----+-------------+
```

こうなりました。
クエリを見て分かる通り、 **内側の`User.transaction`は何のクエリも発行していない**ということがわかりました

これは当然、ネストしたトランザクションを使うという目的は満たしていませんね。

### ネストの中でロールバックした場合

ロールバックした時の挙動もチェックしておきましょう
トランザクションを明示的にロールバックさせるためには、 ActiveRecord::Rollback 例外を投げます
どんな例外を投げてもロールバックされるでしょ？と思われる方もいるかもしれませんが、ちょっと違うんです（後述）

```rb
User.transaction do
  User.create!(id: 1, name: 'before-nest')
  User.transaction do
    User.create!(id: 2, name: 'in-nest')
    raise ActiveRecord::Rollback # <===ココ
  end
  User.create!(id: 3, name: 'after-nest')
end
```

```mysql
BEGIN
INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest')
INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest')
INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest')
COMMIT

mysql> select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  2 | in-nest     |
|  3 | after-nest  |
+----+-------------+
```

ロールバック**されない**という不思議な結果になりました。
理由は後述します

### ネストの後でロールバックした場合

```rb
User.transaction do
  User.create!(id: 1, name: 'before-nest')
  User.transaction do
    User.create!(id: 2, name: 'in-nest')
  end
  User.create!(id: 3, name: 'after-nest')
  raise ActiveRecord::Rollback # <===ココ
end
```
```mysql
BEGIN
INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest')
INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest')
INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest')
ROLLBACK

mysql> select * from users;
Empty set (0.03 sec)
```

全部ロールバックされます。
まあ、これはわかりますよね。トランザクション1つしかないんだし



…どうやら、 ActiveRecoed は普通にtransactionメソッドをネストしてもうまくいかないようです
クエリは発行されないし、ロールバックもしてくれなかったりと挙動もヘンです
どうしたらいいのでしょうか（答えはこの後すぐ）

## オプション `requires_new`

transactionメソッドには、 `requires_new` というオプションがあります。
これを指定すると、明示的に新しいトランザクションを貼ることができるのです。
やってみましょう

### 正常系

```rb
User.transaction do
  User.create!(id: 1, name: 'before-nest')
  User.transaction(requires_new: true) do # <===ココ
    User.create!(id: 2, name: 'in-nest')
  end
  User.create!(id: 3, name: 'after-nest')
end
```
```mysql
BEGIN
INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest')
  SAVEPOINT active_record_1
  INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest')
  RELEASE SAVEPOINT active_record_1
INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest')
COMMIT

mysql> select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  2 | in-nest     |
|  3 | after-nest  |
+----+-------------+
3 rows in set (0.00 sec)
```

`requires_new: true` を指定すると、 `SAVEPOINT` クエリが発行されましたね！ (自動で `active_record_1` という名前がつけられています）
トランザクションブロックが終了した時も、ちゃんと `RELEASE SAVEPOINT` が発行されています
正しく動いていますね

### ネストの中でロールバックした場合

```rb
User.transaction do
  User.create!(id: 1, name: 'before-nest')
  User.transaction(requires_new: true) do
    User.create!(id: 2, name: 'in-nest')
    raise ActiveRecord::Rollback # <===ココ
  end
  User.create!(id: 3, name: 'after-nest')
end
```
```mysql
BEGIN
INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest')
  SAVEPOINT active_record_1
  INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest')
  ROLLBACK TO SAVEPOINT active_record_1
INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest')
COMMIT

mysql> select * from users;
+----+-------------+
| id | name        |
+----+-------------+
|  1 | before-nest |
|  3 | after-nest  |
+----+-------------+
2 rows in set (0.00 sec)
```

内側のトランザクションだけロールバックしています！
いい感じですね

### ネストの後でロールバックする場合

```rb
User.transaction do
  User.create!(id: 1, name: 'before-nest')
  User.transaction(requires_new: true) do
    User.create!(id: 2, name: 'in-nest')
  end
  User.create!(id: 3, name: 'after-nest')
  raise ActiveRecord::Rollback # <=== ココ
end
```
```mysql
BEGIN
INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest')
  SAVEPOINT active_record_1
  INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest')
  RELEASE SAVEPOINT active_record_1
INSERT INTO `users` (`id`, `name`) VALUES (3, 'after-nest')
ROLLBACK

mysql> select * from users;
Empty set (0.01 sec)
```

全部ロールバックされました！
いいですね
ちゃんとネストしたトランザクションとしての挙動をしてくれました


## ActiveRecord::Rollback 以外のエラーが起きた時

transaction の中で例外がおきた時、トランザクションがロールバックされることはご存知と思いますが
`ActiveRecord::Rollback` エラーだけは、ちょっと挙動が違います

```rb
User.transaction do
  User.create!(id: 1, name: 'before-nest')
  User.transaction(requires_new: true) do
    User.create!(id: 2, name: 'in-nest')
    raise # <=== ActiveRecord::Rollback でない例外が起きた
  end
  User.create!(id: 3, name: 'after-nest')
end
```
```mysql
BEGIN
INSERT INTO `users` (`id`, `name`) VALUES (1, 'before-nest')
  SAVEPOINT active_record_1
  INSERT INTO `users` (`id`, `name`) VALUES (2, 'in-nest')
  ROLLBACK TO SAVEPOINT active_record_1
ROLLBACK # <=== ここもロールバックしている

mysql> select * from users;
Empty set (0.00 sec)
```

外側のトランザクションまでロールバックしています
先ほど `ActiveRecord::Rollback` 例外を投げた時は、内側のトランザクションだけがロールバックし、外側のトランザクションは何事もなかったかのように続行されていました
今回は、例外が内側のトランザクションを突き抜けて、外側のトランザクションまで例外によるロールバックを発生させちゃったんですね。（なんなら外側のトランザクションも突き抜けているので、どこかで捕捉しないとプログラムが止まります）
実は ActiveRecord の transaction メソッドは、 `ActiveRecord::Rollback` 例外だけを静かに飲み込んで何事もなかったかのように振舞っているのです (https://github.com/rails/rails/blob/v6.0.2.1/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L283)

これはトランザクションがネストしているかどうかとは関係なく、覚えておきたい点ですね。

### さっきの答え合わせ

`.transaction`を2回貼った時、内側で `raise ActiveRecord::Rollback` を投げた時、ロールバックしなかったという不思議現象がありましたね
あれは、例外が内側のtransactionに捕捉され、外側のトランザクションからすると何事も起こっていないように見えていたからでした。
あのコードでは、内側の transaction メソッドは実際には `BEGIN` や `SAVEPOINT` クエリを発行していないため、対応する `ROLLBACK` `ROLLBACK TO SAVEPOINT` を発行しなかったということのようです
だったら例外も捕捉しないで上にそのまま raise してよ、という気もしますね（この挙動の方が辻褄があうと言うことなんでしょうか…？）

## 貼ってる？トランザクション

参考までに
ActiveRecord には、今トランザクションを貼っているかどうかを知るメソッドがあります

```rb
User.connection.open_transactions
```

このように書くと、現在貼っているトランザクション数が返ってきます

```rb
User.connection.open_transactions # => 0

User.transaction do
  User.connection.open_transactions # => 1
end


User.transaction do
  User.transaction(requires_new: true) do
    User.connection.open_transactions # => 2
  end
end


User.transaction do
  User.transaction do
    User.connection.open_transactions # => この場合は1
  end
end
```

# まとめ

- MySQL
  - ネストしたいなら `SAVEPOINT` 命令を使おう
  - `BEGIN` を2度書くと思わぬことが起きるぞ
  - 厳密にはトランザクションのネストはできないよ
- Rails(ActiveRecord)
  - ネストしたトランザクションを貼るときは、オプション `requires_new: true` を指定しよう
      - 指定しないと予想外の挙動になるよ
  - 内側のトランザクションだけをロールバックする場合は、 `raise ActiveRecord::Rollback` を投げよう
      - それ以外の例外だと外側も一緒にロールバックしちゃうぞ

## まとめのまとめ

可能な限り、トランザクションのネストってしない方がいいよ