MySQL | SQLite経験者向け、30日で習得するMySQL:実務SQL力 - Day14 トランザクション

SQL MySQL
スポンサーリンク

Day14 後半のゴール

「“トランザクション同士がぶつかったとき”に何が起きるかをイメージできるようにする」

前半で、BEGIN / COMMIT / ROLLBACK と「ロック=予約札」のイメージを作りました。
後半では一歩進めて、

どの単位でロックされるのか(行ロック・テーブルロック)
トランザクション同士がぶつかったときに何が起きるのか(待ち・デッドロック)
安全にトランザクションを設計するための考え方

ここを押さえていきます。


行ロックとテーブルロック

「InnoDB は“行ロックが基本”だが、テーブル全体が止まることもある」

MySQL(InnoDB)のロックは、ざっくり言うとこうです。

行ロック
特定の行だけをロックする。「この行は今このトランザクションが触っているから、他の人は待ってね」という状態。

テーブルロック
テーブル全体をロックする。「このテーブルは今この処理が専有しているから、他の人は書き込めない(場合によっては読めない)」という状態。

InnoDB は基本的に「行ロック」が売りです。
つまり、同じテーブルでも、違う行なら同時に更新できます。

ただし、

インデックスが効いていない検索条件で UPDATE / DELETE した
テーブル構造を変える(ALTER TABLE)を実行した

といった場合には、テーブル全体に近い範囲がロックされることがあります。

「どのくらいの範囲がロックされるか」は、
実務ではパフォーマンスと直結するので、
少しずつ感覚を育てていくところです。


行ロックの具体例

「同じ行を同時に更新しようとすると“片方が待つ”」

accounts テーブルをもう一度使います。

CREATE TABLE accounts (
  id      INT AUTO_INCREMENT PRIMARY KEY,
  name    VARCHAR(50) NOT NULL,
  balance INT         NOT NULL
) ENGINE=InnoDB;
SQL

ここで、2つのトランザクションを想像してください。

トランザクションA(ターミナル1)

START TRANSACTION;
UPDATE accounts
SET balance = balance - 10000
WHERE id = 1;
-- ここで COMMIT せずにしばらく止めておく
SQL

トランザクションB(ターミナル2)

START TRANSACTION;
UPDATE accounts
SET balance = balance - 5000
WHERE id = 1;
SQL

このとき、何が起きるか。

トランザクションAが id = 1 の行を更新した瞬間、その行にはロックがかかります。
COMMIT / ROLLBACK でトランザクションAが終わるまで、
トランザクションBの UPDATE は「待ち」状態になります。

BのUPDATEはエラーにはならず、「ずっと返ってこない」ように見えます。
裏では「Aがその行を握っているから、空くまで待っている」状態です。

Aが COMMIT すると、ロックが外れ、
そのタイミングでBのUPDATEが実行されます。

ここで大事なのは、

同じ行を同時に更新しようとすると、どちらかが待つ
違う行なら同時に更新できる

という感覚です。


デッドロックとは何か

「“お互いに相手のロック待ち”になって、永遠に終わらない状態」

もう一歩踏み込んで、デッドロックを見てみます。
これは、トランザクションの世界で一番有名な“事故”です。

シンプルな例を考えます。

トランザクションA

START TRANSACTION;
UPDATE accounts
SET balance = balance - 10000
WHERE id = 1;  -- 行1をロック

-- ここで少し待ってから
UPDATE accounts
SET balance = balance + 10000
WHERE id = 2;  -- 行2を取りに行く
SQL

トランザクションB

START TRANSACTION;
UPDATE accounts
SET balance = balance - 5000
WHERE id = 2;  -- 行2をロック

-- ここで少し待ってから
UPDATE accounts
SET balance = balance + 5000
WHERE id = 1;  -- 行1を取りに行く
SQL

このときの流れを言葉で追うと、

Aが行1をロック
Bが行2をロック
Aが行2を取りに行く → でも行2はBがロック中なので待ち
Bが行1を取りに行く → でも行1はAがロック中なので待ち

AはBがロックを解放するのを待っている
BはAがロックを解放するのを待っている

という「お互いに相手待ち」の状態になります。
これがデッドロックです。

このままだと永遠に終わらないので、
DBはどちらか一方のトランザクションを強制的に ROLLBACK させます。

アプリ側から見ると、

「デッドロックが発生したのでトランザクションをロールバックしました」

というエラーになります。


デッドロックを避ける基本的な考え方

「“同じ順番でロックを取りに行く”を徹底する」

さっきの例で、何が悪かったかというと、

トランザクションAは「行1 → 行2」の順でロックを取りに行った
トランザクションBは「行2 → 行1」の順でロックを取りに行った

という「順番の違い」です。

これを避けるための基本ルールはシンプルです。

複数の行・テーブルを更新するときは、
どのトランザクションも同じ順番でロックを取りに行く

例えば、

必ず「id の小さい方から順に更新する」
必ず「親テーブル → 子テーブル」の順で更新する

といったルールを決めておくと、
デッドロックのリスクをかなり減らせます。

実務では、

アプリ全体で「更新順序のポリシー」を決める
複数テーブルをまたぐ更新は、ストアドプロシージャに寄せて順序を固定する

といった工夫をします。


トランザクションの“長さ”を意識する

「長く握るほど、ロックは周りに影響する」

トランザクションは便利ですが、
長く開きっぱなしにすると、その間ロックも長く残ります。

例えば、

BEGIN
ユーザーからの入力を待つ(数秒〜数十秒)
その後にUPDATEしてCOMMIT

というような設計をしてしまうと、
その間ずっとロックが残り、
他の処理が待たされることになります。

基本的な考え方はこうです。

トランザクションは「必要な処理だけを、できるだけ短く」

具体的には、

ユーザー入力を待つ前にBEGINしない
画面表示用のSELECTと、更新用のUPDATEを同じトランザクションに詰め込みすぎない
重い集計処理をトランザクションの中で長時間走らせない

といったことを意識します。

「BEGIN してから COMMIT までの時間」を短くすることが、
ロックによる影響を小さくする一番のコツです。


トランザクションとエラー処理

「“失敗したら必ず ROLLBACK”をセットで考える」

トランザクションを使うときに、
もう1つ大事なのがエラー処理です。

BEGIN して複数のSQLを実行している途中で、
どこか1つでもエラーが起きたら、

そのトランザクションは「成功」とは言えません。
必ず ROLLBACK して、BEGIN 前の状態に戻すべきです。

ストアドプロシージャで書くときは、

エラーが起きたら ROLLBACK するハンドラを用意する
最後まで成功したときだけ COMMIT する

という形にします。

アプリ側でトランザクションを管理する場合も、

try ブロックで BEGIN〜COMMIT
catch ブロックで ROLLBACK

というパターンを徹底します。

「トランザクションを使う=失敗時のROLLBACKまでセットで設計する」
ここを強く意識しておくと、
中途半端な状態を残す事故をかなり防げます。


セキュリティ・整合性の視点から見たトランザクション

「“壊れてはいけないルール”を守る最後の砦」

トランザクションとロックは、
セキュリティというより「データの整合性」を守る仕組みですが、
結果的にセキュリティにも効いてきます。

例えば、

残高がマイナスになってはいけない
在庫数が実際より多く見えてはいけない
権限テーブルの更新が中途半端な状態で終わってはいけない

こういった「壊れてはいけないルール」を守るために、

複数のUPDATE / INSERT / DELETEを
トランザクションでひとかたまりにし、
ロックで同時更新の競合を防ぐ

という設計をします。

もしここをサボると、

タイミング次第で残高が二重に引かれる
在庫が実際より多く見えて、不正注文が通る
権限が一瞬だけおかしな状態になり、不正操作が可能になる

といった“タイミング依存のバグ”が生まれます。

Day14 の段階では、

トランザクション=「壊れてはいけないルールを守る最後の砦」

という感覚を持っておくと、
どこで真剣にトランザクションを張るべきか、判断しやすくなります。


Day14 後半のまとめ

InnoDB では基本的に「行ロック」が使われ、同じテーブルでも違う行なら同時に更新できる一方、同じ行を別トランザクションが更新しようとすると、先にロックを取った側が優先され、後から来た側はロックが解放されるまで「待ち」状態になる。
複数の行・テーブルをまたいで更新するときに、トランザクションごとにロックを取りに行く順番がバラバラだと、「Aは行1をロックして行2を待ち、Bは行2をロックして行1を待つ」という“お互いに相手待ち”のデッドロックが発生し、MySQL はどちらか一方のトランザクションを強制的に ROLLBACK するため、実務では「常に同じ順番でロックを取りに行く」「トランザクションをできるだけ短く保つ」といった設計ルールが重要になる。
トランザクションを使うときは、BEGIN〜COMMIT の間に行われる変更が「全部成功したらまとめて確定、どこかで失敗したら必ず ROLLBACK で元に戻す」という前提で設計し、特に残高・在庫・権限のような“壊れてはいけないルール”に関わる処理では、トランザクションとロックを意識的に使うことで、「タイミング次第でおかしな状態になる」タイプのバグや不正の余地を潰していく、という発想が実務SQL力の中核になっていく。

タイトルとURLをコピーしました