kawasin73のブログ

技術記事とかいろんなことをかくブログです

MySQL は Rigorous ではない

トランザクションは慎重に。どうも、かわしんです。今トランザクションを実装しています。

タイトルは釣りです。MySQL のデフォルトのトランザクション分離レベルである REPEATABLE READ での話です。後、確かめた訳ではないですが MySQL に限った話ではないです。

さて、トランザクションの用語に Strictness (ST) と Rigorous (RG) というものがあります。詳しくはこの記事を読むとヒントになるかもしれません。

トランザクションの Strictness と Rigorousness の定義を再確認する - ぱと隊長日誌

これら2つの違いはあるトランザクションがあるレコードを Read した時に、Strictness は別のトランザクションでも そのレコードに Write できる のに対して、Rigorous は Read したトランザクションが Commit か Abort するまで Write がブロックされる かの違いです。Rigorous の方が Strictness よりも厳密です。

つまり 2PL の文脈でいえば、内部的に取得した Read Lock を Commit/Abort 前に解放するかどうかの違いになります。

今回はこれを実際の MySQL で試して確認し、実際のアプリケーションでのユースケースで気をつけるべきところを紹介します。

実験をしてみる

今回試したのは、MySQL 8.0.15 です。Docker で動かしています。

テーブルは以下の構造で、1つだけレコードを追加しています。

CREATE TABLE hoge (
  id INT NOT NULL,
  value INT NOT NULL default 0,
  PRIMARY KEY (id)
);
INSERT INTO hoge (id, value) VALUES (1, 1);

2 つのコネクションを作成しそれぞれでトランザクションを作って id = 1 のレコードの value をカウントアップする実験をしていきます。

操作は全て mysql クライアントコマンドで行っています。

普通に値を更新して Rigorous でないことを確かめる

2 つのコネクションからそれぞれ以下の SQL を同時に1行ずつ実行します。

BEGIN;
SELECT * FROM hoge WHERE id = 1;
UPDATE hoge SET value = 2 WHERE id = 1;
SELECT * FROM hoge WHERE id = 1;
COMMIT;

片方で BEGIN した後にもう片方で BEGIN をして ... と行った具合です。

まず、両方のトランザクションid = 1value は 1 であるとわかります。

mysql> SELECT * FROM hoge WHERE id = 1;
+----+-------+
| id | value |
+----+-------+
|  1 |     1 |
+----+-------+
1 row in set (0.00 sec)

そこで両方のトランザクションでその値に 1 を加えた 2 を更新します。

mysql> UPDATE hoge SET value = 2 WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> SELECT * FROM hoge WHERE id = 1;
+----+-------+
| id | value |
+----+-------+
|  1 |     2 |
+----+-------+
1 row in set (0.00 sec)

片方のトランザクションはこのように更新に成功します。少なくとも SELECT したトランザクションがある中で UPDATE が成功しているため、Rigorous ではない ということがわかります。

更新のブロッキングと奇妙な挙動

さて、片方のトランザクションですが、ブロックされレスポンスが返ってきません。

そして、更新に成功したトランザクションCOMMIT するとブロックされたトランザクションが動き始めます。

mysql> UPDATE hoge SET value = 2 WHERE id = 1;
Query OK, 0 rows affected (7.08 sec)
Rows matched: 1  Changed: 0  Warnings: 0

mysql> SELECT * FROM hoge WHERE id = 1;
+----+-------+
| id | value |
+----+-------+
|  1 |     1 |
+----+-------+
1 row in set (0.00 sec)

しかし、値の更新に失敗 します。Changed: 0 となっていることからも更新に失敗していることがわかります。

ここで別の値(例えば 3 とか)に更新すると更新に成功します。

なぜ同じ値だと更新に失敗して違う値だと更新に成功するのかがよくわかりません 。両方成功していいはずです。

これはあくまでも僕の想像ですが、同じ値に更新するということは前の値に対して同じ相対的な操作(この場合はカウントアップ)を行っている可能性が高いです。そのため、意図せぬ競合状態によるデータの不整合が発生している可能性が高く、そのバグに利用者が気付きやすくするためにあえて更新を失敗させているのではないかと思いました。

もしカウントアップなどの相対的な操作ではなく、両方ともその値にするという絶対的な操作であった場合でも更新に失敗しても最終的な値はその値になっているため、データが壊れるということはなくデメリットは小さいです。

こういう利用者のミスに優しい MySQL の親切設計なのではないかと推察しました。

逆に、片方では +1 して片方では +2 するみたいなユースケースでは更新に失敗しないため注意が必要です。

正しくカウントアップを成功させる

SELECT した値に操作をして UPDATE しても更新に失敗することがわかりました。ではどうすれば正しく値のカウントアップができるのでしょうか?

方法は2つあります。

  • ロックをとる
  • UPDATE 文を工夫する

1つは SELECT するときに ロックをとる 方法です。MySQL では、SELECT 文の末尾に FOR UPDATE をつけることでロックを取得できます。ロックを取得したトランザクションが Commit/Abort するまで別のトランザクションのロックはブロックされ、ロックを獲得できた時には更新された値を取得することができます。

SELECT * FROM hoge WHERE id = 1 FOR UPDATE;

もう1つは UPDATE 文を工夫する というものです。値の相対的な操作がカウントアップなど単純な場合は、value = value + 1 とすると相対的な操作がされて更新されます。

UPDATE hoge SET value = value + 1 WHERE id = 1;

別のトランザクションで更新された値に対して加算が行われるため、MySQL の REPEATABLE READ のトランザクション分離レベルであっても、以下のように 1SELECT された値に 1 を足したら 3 になったみたいな一見直感に反する結果になります。

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM hoge WHERE id = 1;
+----+-------+
| id | value |
+----+-------+
|  1 |     1 |
+----+-------+
1 row in set (0.00 sec)

mysql> UPDATE hoge SET value = value + 1 WHERE id = 1;
Query OK, 1 row affected (1.16 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> SELECT * FROM hoge WHERE id = 1;
+----+-------+
| id | value |
+----+-------+
|  1 |     3 |
+----+-------+
1 row in set (0.00 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

SERIALIZABLE ではどうなるか

MySQL は Rigorous ではないと大見得を切ってしまいましたが、MySQL でもトランザクション分離レベルを SERIALIZABLE にすると Rigorous になります。

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

2 つのトランザクションSELECT した上で UPDATE するとブロックされて処理が返ってきません。

そのあとに両方のトランザクションUPDATE するとデッドロックしてしまいます。

mysql> UPDATE hoge SET value = 14 WHERE id = 1;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

SERIALIZABLE は厳密にはなりますが速度が遅くなってしまうため大量の処理を捌くには向いていないです。

まとめ

MySQL の Rigorous の挙動を調べるために色々脱線しましたがまとめると以下のようになります。

  • MySQL のデフォルトのトランザクション分離レベルである REPEATABLE READ では Rigorous ではない
    • ただし同じ値に対して同じ操作をしたと思われる場合は親切にも MySQL は更新を失敗させる
  • 相対的な値の操作をする場合は SELECT ~~ FOR UPDATE してロックをとるか、UPDATE ~~ value = value = 1SQL 内に相対的な操作を記述する。
  • SERIALIZABLE は Rigorous になるが遅いし、容易にデッドロックする

アプリケーションのユースケースとしては、ポイントの加算など値の相対的な操作をすることはよくあると思います。

その際は必ずロックを取るか SQL 内に相対的な操作を記述することでエラーのない整合性の取れたデータ更新ができるようになります。