LoginSignup
43
46

More than 3 years have passed since last update.

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

Posted at

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

前提

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

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

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

テストデータ

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

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 を書いてみたらどうなる?

正常系

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

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

(みやすいようにインデントしてますが本来は不要です)

問題ないようです
(※が、実は問題があります。後述)

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

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 文だけロールバックされています
どうやら正しく動いているようです
(※が、 これも実は問題あり なのです。後述)

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

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 はいくつも同時に貼ることができるため、必ず命令文の後に名前を指定する形になります

正常系

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

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

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

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

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

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

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の中身を続けて書いています。

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
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 例外を投げます
どんな例外を投げてもロールバックされるでしょ?と思われる方もいるかもしれませんが、ちょっと違うんです(後述)

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

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

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

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
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 というオプションがあります。
これを指定すると、明示的に新しいトランザクションを貼ることができるのです。
やってみましょう

正常系

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
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 が発行されています
正しく動いていますね

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

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

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

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

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
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 エラーだけは、ちょっと挙動が違います

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
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 メソッドは実際には BEGINSAVEPOINT クエリを発行していないため、対応する ROLLBACK ROLLBACK TO SAVEPOINT を発行しなかったということのようです
だったら例外も捕捉しないで上にそのまま raise してよ、という気もしますね(この挙動の方が辻褄があうと言うことなんでしょうか…?)

貼ってる?トランザクション

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

User.connection.open_transactions

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

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 を投げよう
      • それ以外の例外だと外側も一緒にロールバックしちゃうぞ

まとめのまとめ

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

43
46
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
46