2026年5月20日

2026年5月20日

MySQLのトランザクションの使い方(START TRANSACTION・COMMIT・ROLLBACK)

MySQL のトランザクションは、複数の SQL をひとまとまりにして、「全部反映する(COMMIT)」か「全部取り消す(ROLLBACK)」かを選べるしくみです。送金のように、片方の UPDATE だけが反映されると困る処理で使います。この記事の内容は、2026年11月時点で MySQL 8.4 の公式マニュアルで確認し、SQL は MySQL 8.4 で動かして確かめています。

前提:トランザクションが効くのは InnoDB のテーブル

トランザクションに対応しているのは InnoDB などのトランザクション対応エンジンです。MySQL 5.5 以降の既定のエンジンは InnoDB です。MyISAM のテーブルへの変更はすぐに保存され、ROLLBACK しても戻りません(警告が出るだけです)。

-- テーブルのエンジンを確かめる
SELECT TABLE_NAME, ENGINE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'testdb';

MyISAM が残っている場合は InnoDBとMyISAMの違い を参照してください。

以降の例では、次のテーブルを使います。

CREATE DATABASE IF NOT EXISTS testdb;
USE testdb;

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

INSERT INTO accounts VALUES (1, 'Alice', 5000), (2, 'Bob', 1000);

基本の流れ:START TRANSACTION → SQL → COMMIT/ROLLBACK

mysql コマンドで接続し(mysql -u ユーザー名 -p testdb)、次のように実行します。

START TRANSACTION;

UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;

COMMIT;

COMMIT を実行するまで、変更は自分の接続からしか見えません。途中でやめたい場合は COMMIT の代わりに ROLLBACK を実行すると、START TRANSACTION 以降の変更がすべて取り消されます。

START TRANSACTION;
UPDATE accounts SET balance = 0 WHERE id = 1;
SELECT * FROM accounts;   -- この接続では 0 に見える
ROLLBACK;
SELECT * FROM accounts;   -- 元の値に戻っている

BEGIN と BEGIN WORK は START TRANSACTION の別名です。ただし、ストアドプロシージャやトリガーの中では BEGIN は BEGIN ... END のブロックの始まりとして扱われるため、トランザクションは START TRANSACTION で始めます。迷ったら、どこでも使える START TRANSACTION に統一するのが簡単です。

コミットしないまま接続が切れた場合、InnoDB はそのトランザクションをロールバックします。

autocommit:何もしなければ1文ずつ確定する

MySQL は既定で autocommit が有効です。START TRANSACTION を使わずに実行した SQL は、1文ごとに自動でコミットされ、あとから ROLLBACK で戻すことはできません。

SELECT @@autocommit;   -- 1 なら有効

SET autocommit = 0; にすると、その接続では COMMIT するまで確定しなくなります。ただし、コミットを忘れたままの接続がロックを持ち続ける原因になるので、必要な処理だけを START TRANSACTION で囲む方法をおすすめします。

SAVEPOINT:途中まで戻す

トランザクションの途中に名前付きの地点を作り、そこまでだけ取り消せます。

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT step1;
UPDATE accounts SET balance = balance + 100 WHERE id = 99;  -- 間違えた
ROLLBACK TO SAVEPOINT step1;   -- step1 以降だけを取り消す
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

ロールバックできない文(暗黙のコミット)

CREATE TABLE・ALTER TABLE・DROP TABLE・TRUNCATE TABLE などの DDL や、ユーザー管理の文(CREATE USER・GRANT など)は、実行の前にそれまでのトランザクションを自動でコミットします。トランザクションの途中に DDL を入れると、そこまでの変更は ROLLBACK で戻せなくなります。

また、トランザクションの途中で START TRANSACTION を実行すると、実行中のトランザクションはコミットされます。入れ子にはなりません(途中まで戻したい場合は SAVEPOINT を使います)。

同時に更新するとき:行ロックと SELECT ... FOR UPDATE

InnoDB は、UPDATE や DELETE で変更した行にロックをかけ、コミットかロールバックまで保持します。別の接続が同じ行を更新しようとすると、ロックが外れるまで待たされます。

残高を確かめてから引き落とすような処理では、確かめる SELECT にも FOR UPDATE を付けて、ほかの接続が同時に同じ行を書き換えられないようにします。

START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- この行をロック
-- 残高が足りることをアプリ側で確かめてから
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
COMMIT;

FOR UPDATE を付けない普通の SELECT は、ロックをかけずにスナップショットを読みます。InnoDB の既定の分離レベルは REPEATABLE READ で、同じトランザクションの中で普通の SELECT を繰り返すと、最初に読んだ時点の内容が返ります。

SELECT @@transaction_isolation;   -- REPEATABLE-READ

ロック待ちのタイムアウトとデッドロック

状況 エラー 何が取り消されるか
ロックを innodb_lock_wait_timeout 秒(既定50秒)待っても取れない 1205 Lock wait timeout exceeded 既定では待っていた文だけ。トランザクションは続いているので、アプリ側で ROLLBACK するか再実行するかを決める
2つのトランザクションが互いのロックを待つ(デッドロック) 1213 Deadlock found InnoDB が片方のトランザクション全体をロールバックする

デッドロックは、複数の行を更新する順番が処理ごとに違うと起きやすくなります。行を更新する順番をそろえ、トランザクションを短くし、1213 が返ったらトランザクションを最初からやり直すようにアプリを作ります。発生したデッドロックの内容は SHOW ENGINE INNODB STATUS\G の LATEST DETECTED DEADLOCK の部分で確かめられます。詳しくは MySQLのデッドロックの解決方法 を参照してください。

エラーのときに戻す処理を書く

SQL の IF ... THEN ... END IF は、ストアドプロシージャなどの中でしか使えません。mysql コマンドに直接打ち込んでも構文エラーになります。条件によってコミットとロールバックを分けたい場合は、ストアドプロシージャにするか、アプリケーション側で書きます。

ストアドプロシージャで書く

次のプロシージャは、残高が足りない、または振込先が無い場合にエラーを出し、エラーが起きたらロールバックしてエラーを呼び出し元に返します。

DELIMITER //

CREATE PROCEDURE transfer(IN from_id INT, IN to_id INT, IN amount INT)
BEGIN
  DECLARE cur_balance INT DEFAULT NULL;

  -- どこかでエラーが起きたらロールバックして、エラーをそのまま返す
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    RESIGNAL;
  END;

  START TRANSACTION;

  SELECT balance INTO cur_balance FROM accounts WHERE id = from_id FOR UPDATE;
  IF cur_balance IS NULL OR cur_balance < amount THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'insufficient balance or no account';
  END IF;

  UPDATE accounts SET balance = balance - amount WHERE id = from_id;
  UPDATE accounts SET balance = balance + amount WHERE id = to_id;
  IF ROW_COUNT() = 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'destination account not found';
  END IF;

  COMMIT;
END//

DELIMITER ;
CALL transfer(1, 2, 500);    -- 成功してコミットされる
CALL transfer(1, 2, 99999);  -- ERROR 1644: insufficient balance or no account(変更なし)
CALL transfer(1, 99, 100);   -- ERROR 1644: destination account not found(引き落としも取り消される)

DELIMITER // は mysql コマンドの機能で、プロシージャの中の ; で文が終わったと判断されないようにするためのものです。phpMyAdmin などでは、画面の「区切り文字」の欄で指定します。

PHP(PDO)で書く

アプリケーションから使う場合は、ドライバーのトランザクション用のメソッドを使います。PHP 8.0 以降の PDO は、既定でエラー時に例外を投げます(下の例では念のため明示しています)。

<?php
$pdo = new PDO(
	'mysql:host=localhost;dbname=testdb;charset=utf8mb4',
	'app_user',
	'パスワード',
	[ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ]
);

function transfer( PDO $pdo, int $from, int $to, int $amount ): void {
	$pdo->beginTransaction();
	try {
		$stmt = $pdo->prepare( 'SELECT balance FROM accounts WHERE id = ? FOR UPDATE' );
		$stmt->execute( [ $from ] );
		$balance = $stmt->fetchColumn();
		if ( false === $balance || (int) $balance < $amount ) {
			throw new RuntimeException( 'insufficient balance or no account' );
		}

		$pdo->prepare( 'UPDATE accounts SET balance = balance - ? WHERE id = ?' )
			->execute( [ $amount, $from ] );
		$stmt = $pdo->prepare( 'UPDATE accounts SET balance = balance + ? WHERE id = ?' );
		$stmt->execute( [ $amount, $to ] );
		if ( 0 === $stmt->rowCount() ) {
			throw new RuntimeException( 'destination account not found' );
		}

		$pdo->commit();
	} catch ( Throwable $e ) {
		$pdo->rollBack();
		throw $e;
	}
}

transfer( $pdo, 1, 2, 500 );

値はプレースホルダー(?)で渡し、SQL の文字列に直接埋め込まないようにします。

運用での注意

  • トランザクションは短くします。開いている間は行ロックを持ち続け、ほかの処理を待たせます。ユーザーの入力待ちや外部 API の呼び出しをトランザクションの中に入れないようにします。
  • 長時間開いたままのトランザクションは、次の SQL で探せます。
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started;
  • 原因の接続が分かったら、その接続を止めるかどうかを判断します(KILL スレッドID; で切断すると、その接続のトランザクションはロールバックされます)。

まとめ

  • 複数の更新を「全部反映か全部取り消し」にしたいときは、START TRANSACTION で始め、COMMIT か ROLLBACK で終えます。対象のテーブルは InnoDB である必要があります。
  • 既定では autocommit が有効で、1文ずつ確定します。
  • DDL などは暗黙にコミットするので、トランザクションの途中に入れません。
  • 読んでから書き換える処理では SELECT ... FOR UPDATE で行をロックします。デッドロック(1213)が返ったらやり直します。
  • 条件でロールバックする処理は、ストアドプロシージャ(DECLARE ... HANDLER)かアプリ側(PDO の beginTransaction/commit/rollBack)で書きます。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲