【MySQL】手を動かして学ぶトランザクション入門
「これを見たらトランザクション周りがざっくり分かる」を目指します。
MySQLを前提に解説しますが、他のDBMSでもベースとなる部分は同じだと思います。
トランザクションとは?
ざっくり言うと「ここからここまでワンセットです」な処理のことです。
たとえば、以下のようなSQLが2つあったとします。
在庫を減らすSQL;
発送処理をするSQL;
この2つは絶対にセットで実行したいとします。
ですが、この2つを実行した結果「1つ目は成功したけど2つ目は失敗した」となった場合、「在庫だけ減ってしまった!」になってしまいます。
こういうときにトランザクションが使えます。
この2つをトランザクションとして実行すると、以下のどちらかに強制できます。
- すべて実行する
- すべて実行しない
つまり、中途半端に実行されるのを防げます。
トランザクションの書き方
トランザクションとして実行するには、BEGIN
とCOMMIT
で囲います。
BEGIN;
在庫を減らすSQL;
発送処理をするSQL;
COMMIT;
これだけで、この2つのSQLをトランザクションとして実行できます。
「BEGIN
でトランザクションを開始して、COMMIT
で結果を確定させる」というイメージです。
また、COMMIT
の代わりにROLLBACK
と書くと「全部実行しなかったことにする」ができます。
たとえば、以下のように書くと最終的に何も実行しなかったことになります。
BEGIN;
在庫を減らすSQL;
発送処理をするSQL;
ROLLBACK;
ロックとは?
ロックは「このデータは今私が使っているので使わないでください」ができる機能です。
ロックは以下の2種類があります。
- 排他ロック(占有ロックなどとも言う)
- 「このデータは私が今使っているので使わないでください。見るのも許可しません」ができる。
- 共有ロック
- 「このデータは私が今使っているので使わないでください。ただし見るだけなら許可します」ができる。
排他ロックの掛け方
おもに3つあります。
- SELECT FOR UPDATE
- アドバイザリロック
- UPDATE、DELETE、INSERT
1. SELECT FOR UPDATE
SELECT文の最後にFOR UPDATE
をつける方法です。
たとえば、以下のように書くと「SELECTでレコードを取得するついでに排他ロックをかけたろ!」ができます。
BEGIN;
SELECT * FROM classmates WHERE id = 1 FOR UPDATE;
-- classmateId=1のレコードを他のトランザクションから更新できなくなる
...
COMMIT;
実際にSQLを実行してみます👇️
以下のような流れになっているのが確認できます。
- セッション1でSELECT FOR UPDATEを実行する
- セッション2でSELECT FOR UPDATEを実行すると、待機させられる
- セッション1でCOMMITしたと同時に、セッション2の待機が解除されてロックが取得できる
2. アドバイザリロック
アドバイザリロックは「好きな名前のロックを取得するよ〜」ができる機能です。
たとえば以下のように書きます。
-- hogeという名前のロックを取りに行く(10秒待って取れなかったら失敗)。
SELECT GET_LOCK('hoge', 10);
-- ロックを取得できたら処理を続行
-- ...
-- ロックを解放
SELECT RELEASE_LOCK('hoge');
このとき、hoge
という名前のアドバイザリロックを他のトランザクションが取りに来ても「hoge
は今使ってるから拒否します」ができます。
実際にSQLを実行してみます👇️
SELECT FOR UPDATEと同じく、ロックが掛かることが確認できます。
違いは以下のとおりです。
- SELECT FOR UPDATE:
- 「レコード」にロックをかける
- アドバイザリロック:
- 「自分が決めた名前」にロックをかける
ちなみにアドバイザリロックはDBMSによって書き方が違います。
なので、お使いのDBMSの仕様を確認してみてください。
3. UPDATE、DELETE、INSERT
以下のSQLは全て、排他ロックをこっそり取得して実行します。
- UPDATE
- DELETE
- INSERT
たとえば、以下のようなUPDATE文を実行する場合を考えます。
UPDATE classmates SET name = 'すねお' WHERE id = '1';
このUPDATE文を実行したと同時に、ほかのトランザクションAがid = 1
のレコードを更新しようとしたとします。その場合、このUPDATE文が終わるまでトランザクションAは待たされることになります。
共有ロックの掛け方
SELECT文の最後にFOR SHARE
をつける方法があります。
たとえば、以下のように書くと「SELECTでレコードを取得するついでに共有ロックをかけよう!」ができます。
BEGIN;
SELECT * FROM classmates WHERE id = 1 FOR SHARE;
-- classmateId=1のレコードを他のトランザクションから更新できなくなる。ただし読み取りはできる。
...
COMMIT;
実際にSQLを実行してみます。
まず、FOR SHARE → FOR SHAREの場合にロックがかからないことを確認します👇️
次にFOR SHARE → FOR UPDATEの場合にロックがかかることを確認します👇️
トランザクション分離レベルとは?
ここからが少しややこしい話です。
トランザクション分離レベルは「同時に動いている複数のトランザクションが、互いに影響しないようにしようね」の設定です。
SQL標準では、以下の4つが定義されています。
- READ UNCOMMITTED
- READ COMMITTED(PostgreSQLのデフォルト)
- REPEATABLE READ(MySQLのデフォルト)
- SERIALIZABLE
それぞれ以下の特徴を持ちます。
ダーティリード | ファジーリード | ファントムリード | |
---|---|---|---|
READ UNCOMMITTED | ❌️ | ❌️ | ❌️ |
READ COMMITTED | ✅️ | ❌️ | ❌️ |
REPEATABLE READ | ✅️ | ✅️ | ❌️ ※ |
SERIALIZABLE | ✅️ | ✅️ | ✅️ |
✅️:防止される
❌️:発生しうる
※: MySQLやPostgreSQLでは起きません(後述します)
- ファジーリード
- COMMITされたトランザクション1の"変更"をトランザクション2が参照できちゃう現象
- ファントムリード:
- COMMITされたトランザクション1の"追加/削除"をトランザクション2が参照できちゃう現象
- ダーティリード:
- COMMITされていないトランザクション1の"変更/追加/削除"をトランザクション2が参照できちゃう現象
急にムズかしい話をするな!と思うかもですが、これはこの表と◯◯リードの意味をそのまま覚えてしまったほうが近道だと思います。なのでそのまま書きました。
そしてこれらを理解するには、自分で手を動かしてみて「どういう動きになるのか」を確認するのが手っ取り早いです。
というわけで、ここからは実際に手を動かして確認してみます。
そのために以下のテーブルを用意します。
CREATE TABLE `classmates` (
`id` int NOT NULL COMMENT 'Primary Key',
`name` varchar(255) DEFAULT NULL,
`score` int DEFAULT NULL,
PRIMARY KEY (`id`)
);
INSERT INTO classmates VALUES (1, 'のびた', 0);
「のび太の成績が0点」というレコードが1つだけ入っている状態です。
このレコードに対して、それぞれのトランザクション分離レベルで「のび太の成績を100点にする」というUPDATE文を実行してテストしてみます。
READ UNCOMMITTED
最初にREAD UNCOMMITTEDです。
ダーティリードが発生するか?を確認します👇️
READ COMMITTED
次にREAD COMMITTEDです。
まず、ダーティリードが解消しているか?を確認します👇️
次に、ファジーリードが発生するか?を確認します👇️
REPEATABLE READ
次にREPEATABLE READです。
まず、ファジーリードが解消しているか?を確認します👇️
次に、ファントムリードが発生するか?を確認します👇️
あれ?ファントムリードが起こりませんね・・・?
そうなのです。
実は、MySQLのREPEATABLE READでは、ファントムリードは起こらないのです!
あれ?さっきの表だと❌️って書いてなかったっけ?と思うかもですが、先ほどの表はあくまで”標準”であって、「SQL標準ではこれらの分離レベルで◯◯リードが起こらないようにすべき」を定めたものです。
つまり標準とは別に、MySQLでは独自に「REPEATABLE READではMVCCでファントムリードを起こらないようにする」拡張をしているわけです。
その上で、ファントムリードが起こらない理由は、MVCC(Multi Version Concurrency Control) という仕組みによるものです。
MVCCは「各レコードをバージョン管理できるようにしよう」な仕組みです。
このMVCCによって、REPEATABLE READでは「トランザクション内で発行される同じSELECT文は、最初のSELECT時点のバージョンを見続ける」という挙動になります。
つまりこの場合、❷のSELECT文の結果を見続けることになります。
SERIALIZABLE
最後にSERIALIZABLEです。
SERIALIZABLEは、名前のとおり「すべてのトランザクションが直列に実行されてるかのように動作するモード」です。
なので、それぞれのトランザクションが一切影響を受けません。すべての異常を回避できます。
その代わりに、直列のように動作するのでパフォーマンスが落ちます(単純なSELECT文にも自動的に共有ロックをつけて実行したりします)。
悲観的ロックと楽観的ロックとは?
ロックの方針として、悲観的ロックと楽観的ロックという考え方があります。
ざっくり以下のように違います。
- 楽観的ロック
- 「途中で不整合が起こったら最初からやり直せばいいと思うので、なるべくロックしないようにしよう」
- 悲観的ロック
- 「途中で不整合が起こったらイヤなので、不整合が起こるかもしれない部分はロックするようにしよう」
悲観的ロックでは、必要なタイミングで「排他ロック」や「共有ロック」をガンガンかけます。
楽観的ロックでは、なるべく「排他ロック」や「共有ロック」をかけません。とは言えロックをかけなかったら他で更新された場合に不整合が起こってしまうので、以下をします。
- 更新したいテーブルにバージョンを管理するカラムを追加して、バージョンが変わってたら最初からやり直す。
たとえば、楽観的ロックでは、以下のようなSQLを書きます👇️
SELECT stock, version
FROM products
WHERE product_id = 123;
(何らかの処理)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE product_id = 123 AND version = :old_version;
2番目のUPDATE文で「1番目のSELECTで取得した商品のバージョンが同じだった場合のみ更新する」というのをしています。
もし同じじゃなかった場合、アプリケーション側で「最初からやり直す」をするイメージです。
ちなみに、楽観的ロックだからといって「排他ロック」や「共有ロック」をかけないわけではなく、必要な場面ではかけます。あくまで方針の話なので「楽観的ロックと悲観的ロックを掛け合わせる」のもありえます。
デッドロックとは?
デッドロックは「お互いにロックしちゃって身動きできないよ〜」のことです。
たとえば、以下みたいな状況のことです。
- トランザクションAがレコード1をロックして、レコード2のロックを待っている
- トランザクションBがレコード2をロックして、レコード1のロックを待っている
この状態になると、両方のトランザクションが永遠に待ち続けることになります(実際にはデッドロックエラーになって片方が強制的にROLLBACKされる)。
以下のような操作をすると、実際にデッドロックを起こせます👇️
デッドロックが起きるとアプリケーションとして致命的です。なので絶対に起こらないようにします。
デッドロックを防ぐには、以下などに気をつけます。
- ロックするテーブルの順番を統一する
- たとえばアルファベット順にするなど。
- トランザクションはなるべく短くする
- 不必要に FOR UPDATE でロックを掛けない
あと後述しますが、MySQLの場合はネクストキーロックによってもデッドロックが発生する可能性があります。
ネクストキーロックとは?
MySQLのREPEATABLE READでは、ファントムリードを防ぐためにMVCCの他にも「ネクストキーロック」という仕組みも導入されています。
「ネクストキーロック」はザックリいうと「存在しないレコードのロックを取得しようとした場合、そのレコードを含む空レコード全部と場合によっては空レコード全部の後ろにある実レコードもロックしちゃおう」みたいな仕組みのことです。
・・・この説明だと意味不明だと思うので、実際にSQLを打ってみます。
ネクストキーロックを発生させてみる
まず、以下のテーブルを用意します。
CREATE TABLE `classmates` (
`id` int NOT NULL COMMENT 'Primary Key',
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
INSERT INTO classmates VALUES (1, 'のびた');
INSERT INTO classmates VALUES (2, 'しずか');
INSERT INTO classmates VALUES (3, 'すねお');
INSERT INTO classmates VALUES (4, 'たけし');
INSERT INTO classmates VALUES (10, '出木杉');
idの5~9のレコードが空になっている状態です。
この状態で、以下のように操作してみます(分離レベルはREPEATABLE READです)。
すると、セッション2のINSERTでロックが取得できずにロック解除待ちになります。
これはセッション1のselect * from classmates where id = 5 for update;
の部分で、5〜9にロックが掛かるためです。
ロックが掛かるのは、以下のような動きになるためです。
- 「あれ、id=5を取得しようとしたけどid=5は存在しないじゃん。うーん、別のトランザクションでここに挿入されたらファントムリードが起こっちゃうから存在しない部分を全部ロックしとくか!
この挙動のことをネクストキーロックと呼びます。
ネクストキーロックの特徴
ネクストキーロックは、以下のように2つの意味が合わさった用語です。
ネクストキーロック = ギャップロック + レコードロック
ギャップロックはその名の通り「存在しない部分を全部ロックしとくか!」です。
レコードロックは「存在するレコードをロックしとくか!」です(普通のロックと同じ)。
「どういう場合にどういうネクストキーロックがかかるのか」は以下の記事がわかりやすいです。
ネクストキーロックとは | ソフトウェア雑記
こちらの記事でも書かれていますが、以下が重要です!
- ギャップロック同士は競合しない
- ギャップロックとレコードロックは競合する(もちろんレコードロック同士も競合する)
特に前者の「ギャップロック同士は競合しない」は、デッドロックを引き起こす可能性があります。
ギャップロックによるデッドロック
たとえば、以下のように操作するとデッドロックが起きてしまいます。
ポイントとしては以下です。
- ❷と❸はそれぞれロックをかけているにも関わらず、ギャップロック同士は競合しないので、お互いに待機せずにすぐに実行が終わる。
- その後、❹と❺でお互いロックをかけている状態でINSERTしようとしてデッドロックがかかる。
ネクストキーロックによるFOR UPDATEの挙動
この時点でややこしすぎてお腹いっぱいだと思いますが、まだややこしいことがあります。
それは、ネクストキーロックがある影響で、REPEATABLE READにおいてFOR UPDATEを付けた場合の挙動が以下のように変わる点です。
- FOR UPDATEを付けた場合
- スナップショットを参照せずに常に最新のレコードを読みに行く
- FOR UPDATEを付けなかった場合
- 必ずMVCCのスナップショットを参照する
この挙動はかなり罠です。「考えたらまぁそうか」という感じですが、とても忘れそうです。
しかも更にややこしいことに、以下のようにFOR UPDATEを付けたSELECTと付けていないSELECTを混在させてしまうと、そのトランザクション内で不整合が起きてしまいます👇️
なので「一貫したデータがほしい!」という場合は混ぜないようにする必要があります。
まとめ
- トランザクションは「ここからここまで全部成功 or 全部失敗」で処理をまとめる仕組み。
- ロック(排他ロック/共有ロック)を使って、同時実行時の更新・読み取りの衝突を制御する。
- 分離レベルを調整して、「他のトランザクションの変更をどこまで許容するか」を決める。
- MySQLではネクストキーロックによってREPEATABLE READでファントムリードが起きないけど、ギャップロックでデッドロックに注意。
- 悲観的ロックか楽観的ロックかは運用方針によって使い分ける。
- デッドロックはロックが複雑に絡み合うと起きるので、アプリ側でも対策や再試行ロジックが必要。
※動作確認に使ったバージョン:MySQL 8.0.40、PostgreSQL 15.3 (Debian 15.3-1.pgdg120+1)
\ PR /
弊社は「プラハチャレンジ」という中級者になりたい初級者エンジニア向けのブートキャンプのサービスをやっています。
この記事のような「エンジニアとして知っておくべきだよね」という内容を網羅的に学べるので、興味のある方はぜひ〜。
Discussion