トランザクション基礎
RDB の信頼性はトランザクションで支えられます。 「受注登録と在庫更新は同時に成功/失敗させるべき」「送金の引き落としと入金は片方だけ実行されてはいけない」 ――こうした要求を実現する仕組みがトランザクションです。
トランザクションとは
トランザクションは、複数のSQL文をひとまとまりの作業単位として扱う仕組みです。 すべて成功すればコミット(確定)し、途中で失敗すればロールバック(全取消)します。
基本構文
BEGIN; -- トランザクション開始
UPDATE account SET balance = balance - 10000 WHERE account_id = 'A001';
UPDATE account SET balance = balance + 10000 WHERE account_id = 'B002';
COMMIT; -- 両方成功 → 確定
BEGIN;
UPDATE account SET balance = balance - 10000 WHERE account_id = 'A001';
UPDATE account SET balance = balance + 10000 WHERE account_id = 'B002';
-- ↑ ここでエラー発生
ROLLBACK; -- 全取消(A001の引き落としも元に戻る)
業務での例
| 業務 | トランザクションに含める操作 |
|---|---|
| 銀行送金 | 送金元の残高減少 + 送金先の残高増加 |
| 受注登録 | 受注ヘッダ挿入 + 受注明細挿入 + 在庫引当 |
| ECサイト購入 | 注文作成 + 在庫減少 + ポイント付与 |
多くのRDBMSでは、単独のSQL文も暗黙的にトランザクションとして扱われます(自動コミットモード)。明示的にBEGINを書くのは、複数の文をまとめたいときです。
ACIDの詳細
トランザクションが満たすべき4つの性質をACIDと呼びます。
Atomicity(原子性)
トランザクション内の操作はすべて成功するか、すべて失敗するかのどちらかです。中途半端な状態は残りません。
送金で「引き落とし済み・入金未了」という状態が起きないのは、原子性のおかげです。
Consistency(一貫性)
トランザクションの前後で、DB上の制約(PK/FK/CHECK/UNIQUE等)がすべて満たされた状態を維持します。制約に違反する操作はトランザクション全体が拒否されます。
-- 在庫数が負になるとCHECK制約に違反 → トランザクション失敗
BEGIN;
UPDATE inventory SET quantity = quantity - 100
WHERE product_id = 'P001'; -- 在庫が50しかない場合
-- CHECK (quantity >= 0) 違反 → ROLLBACK
Isolation(分離性)
同時に実行される複数のトランザクションが互いに干渉しないように制御します。あるトランザクションの途中結果が、他のトランザクションから見えないようにします(分離レベルにより程度が異なる)。
Durability(永続性)
コミットが完了したデータは、直後にシステムがクラッシュしても失われないことを保証します。RDBMSはWAL(Write-Ahead Logging)などの仕組みで、コミット済みデータをディスクに永続化します。
| 特性 | 一言で言うと | 守られないと何が起きるか |
|---|---|---|
| Atomicity | 全部か無か | 送金の片方だけ実行される |
| Consistency | 制約遵守 | 在庫がマイナスになる |
| Isolation | 互いに見えない | 他人の未確定データを読んで判断する |
| Durability | コミットは永続 | クラッシュ後にデータが消える |
同時実行で起きる問題
分離性が不十分だと、同時実行で以下の問題が起きます。
ダーティリード(Dirty Read)
他のトランザクションがまだコミットしていない変更を読んでしまう問題です。
-- Tx-A: 在庫を100→50に更新(まだCOMMITしていない)
-- Tx-B: 在庫を読む → 50と見える
-- Tx-A: ROLLBACK(在庫は100に戻る)
-- Tx-B: 50を前提に処理を続行 → 不整合
ノンリピータブルリード(Non-Repeatable Read)
同じトランザクション内で同じ行を2回読んだら値が変わっている問題です。
-- Tx-A: 商品P001の単価を読む → 1200円
-- Tx-B: 商品P001の単価を1500円にUPDATE → COMMIT
-- Tx-A: 同じ商品P001の単価をもう一度読む → 1500円(変わった!)
ファントムリード(Phantom Read)
同じ条件で検索したら、前回なかった行が出現する(または消える)問題です。
-- Tx-A: SELECT COUNT(*) FROM orders WHERE order_date = '2026-02-16' → 10件
-- Tx-B: INSERT INTO orders (...) → COMMIT(1件追加)
-- Tx-A: 同じSELECT → 11件(増えた!)
ロストアップデート(Lost Update)
2つのトランザクションが同じ行を読んで更新し、片方の更新が上書きされて失われる問題です。
-- 在庫: 100
-- Tx-A: 在庫を読む → 100。 100 - 30 = 70 を書き込む準備
-- Tx-B: 在庫を読む → 100。 100 - 20 = 80 を書き込む準備
-- Tx-A: UPDATE → 70
-- Tx-B: UPDATE → 80(Tx-Aの -30 が消えた!正解は 50)
ロストアップデートは「アプリ側で読んだ値を元に計算して書き戻す」設計で起きやすいです。UPDATE inventory SET quantity = quantity - 30 のようにDB側で計算する方が安全です。
分離レベル
SQL標準では4段階の分離レベルが定義されています。レベルが高いほど安全ですが、性能への影響も大きくなります。
分離レベルと防げる問題のマトリックス
| 分離レベル | ダーティリード | ノンリピータブルリード | ファントムリード |
|---|---|---|---|
| READ UNCOMMITTED | 発生する | 発生する | 発生する |
| READ COMMITTED | 防止 | 発生する | 発生する |
| REPEATABLE READ | 防止 | 防止 | 発生する |
| SERIALIZABLE | 防止 | 防止 | 防止 |
主要RDBMSのデフォルト分離レベル
| RDBMS | デフォルト | 備考 |
|---|---|---|
| PostgreSQL | READ COMMITTED | MVCC方式。REPEATABLE READも実用的 |
| MySQL (InnoDB) | REPEATABLE READ | MVCC + ギャップロックでファントムも一部防止 |
| Oracle Database | READ COMMITTED | MVCC方式。SERIALIZABLEは楽観的 |
| SQL Server | READ COMMITTED | SNAPSHOT分離レベルも選択可能 |
実務では、まずデフォルトの分離レベルで運用し、特定の処理で問題が確認された場合にのみ、その処理の分離レベルを引き上げるのが一般的です。
分離レベルの設定例
-- セッション単位で変更
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- PostgreSQLでトランザクション単位で変更
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... 処理 ...
COMMIT;
ロックの基礎
分離性を実現する主要な仕組みがロック(施錠)です。
共有ロックと排他ロック
| 種類 | 別名 | 動作 | 用途 |
|---|---|---|---|
| 共有ロック(S) | 読み取りロック | 他のSロックと共存可、Xロックはブロック | SELECT FOR SHARE |
| 排他ロック(X) | 書き込みロック | 他のS/Xロック両方をブロック | UPDATE, DELETE, SELECT FOR UPDATE |
ロックの粒度
| 粒度 | 特徴 |
|---|---|
| 行ロック | 対象行だけロック。並行性が高いが管理コストも高い |
| ページロック | データページ(数KB単位)をロック。中間的 |
| テーブルロック | テーブル全体をロック。管理は軽いが並行性が低い |
楽観ロックと悲観ロック
| 方式 | 仕組み | 適するケース |
|---|---|---|
| 悲観ロック | SELECT FOR UPDATE で行を事前にロックする |
競合が頻繁に起きるケース(在庫引当など) |
| 楽観ロック | 更新時にバージョン番号や更新日時を比較し、変わっていたらエラーにする | 競合がまれなケース(ユーザー情報編集など) |
-- 悲観ロックの例
BEGIN;
SELECT * FROM inventory WHERE product_id = 'P001' FOR UPDATE;
-- ↑ この行は他のトランザクションからUPDATEできなくなる
UPDATE inventory SET quantity = quantity - 10 WHERE product_id = 'P001';
COMMIT;
-- 楽観ロックの例(アプリ側で実装)
-- 1. 読み取り時にバージョンを取得
SELECT quantity, version FROM inventory WHERE product_id = 'P001';
-- → quantity=100, version=5
-- 2. 更新時にバージョンを条件に含める
UPDATE inventory
SET quantity = 90, version = 6
WHERE product_id = 'P001' AND version = 5;
-- → 更新件数が0なら「他者が先に更新した」と判断してリトライ
デッドロック
2つ以上のトランザクションが互いに相手のロック解放を待ち合って永久に進まなくなる状態です。
発生メカニズムの例
| 時刻 | Tx-A | Tx-B |
|---|---|---|
| T1 | 商品P001をロック | 商品P002をロック |
| T2 | 商品P002をロックしたい → Tx-Bを待つ | (処理中) |
| T3 | (待機中) | 商品P001をロックしたい → Tx-Aを待つ |
| T4 | 互いに待ち合い → デッドロック発生 | |
RDBMSの対処
ほとんどのRDBMSにはデッドロック検出機構があり、検出すると一方のトランザクションを強制ロールバックして解消します。 アプリ側はそのエラーを受け取ったらリトライするのが定石です。
回避策
- ロック順序を統一する: 全トランザクションで商品IDの昇順にロックを取ると決めれば、循環待ちは起きない
- トランザクションを短くする: ロック保持時間を最小化する
- 大きな粒度のロックを避ける: テーブルロックよりも行ロックを使う
- タイムアウトを設定する: 一定時間で諦めてリトライする
デッドロックは「起きないように設計する」と同時に「起きたときにリトライで回復する」の両面で対策します。発生を完全にゼロにするより、検出とリカバリを正しく実装するほうが現実的です。
トランザクション境界の設計
アプリケーション層では「どこからどこまでを1つのトランザクションにするか」を明確に設計する必要があります。
原則: 1業務操作 = 1トランザクション
業務的に「一緒に成功/失敗すべき操作群」を1トランザクションにまとめます。
| 業務操作 | トランザクションに含める範囲 |
|---|---|
| 受注登録 | 受注ヘッダINSERT + 受注明細INSERT(全行) + 在庫引当UPDATE |
| 送金 | 送金元UPDATE + 送金先UPDATE + 取引履歴INSERT |
| ユーザー登録 | ユーザーINSERT + 初期設定INSERT + ウェルカムメール送信キューINSERT |
長すぎるトランザクションの問題
- ロック保持時間が長くなる: 他のトランザクションがブロックされ、スループットが低下する
- ロールバック時のコストが大きい: 大量の変更を巻き戻すのに時間がかかる
- MVCC環境でも負荷が増える: 古いバージョンのデータを長時間保持する必要が生じる
「1000件のバッチ処理を1トランザクションで実行」は危険です。100件ずつに分割し、各バッチを個別のトランザクションにするのが安全です。ただし、業務的に全件まとめて成功/失敗すべき場合はこの限りではありません。
トランザクション外に出すべき処理
- 外部API呼び出し(決済API、メール送信など): タイムアウトでトランザクションが長期化するリスクがある
- 重いファイル処理: トランザクション内でロックを保持したまま時間がかかる
-- 良い例: 外部API呼び出しはトランザクションの外
BEGIN;
INSERT INTO orders (...) VALUES (...);
UPDATE inventory SET quantity = quantity - 10 WHERE ...;
COMMIT;
-- コミット成功後に外部API呼び出し
call_payment_api(order_id);
-- API失敗時は補償トランザクション(取消処理)で対応
実務のベストプラクティス
1. トランザクション内でユーザー入力を待たない
BEGIN → SELECT FOR UPDATE → ユーザーの確認画面 → UPDATE → COMMIT
のような設計はロック保持時間が不定になり、他の処理を長時間ブロックします。
ユーザー入力は楽観ロックで対応し、DB操作は一気に行うのが基本です。
2. タイムアウトを設定する
-- PostgreSQL: ロック待ちタイムアウト
SET lock_timeout = '5s';
-- MySQL: InnoDBのロック待ちタイムアウト(秒)
SET innodb_lock_wait_timeout = 10;
3. デッドロック時はリトライする
デッドロックでロールバックされたら、同じ処理をリトライします。アプリ層でリトライ回数(通常3回程度)と待機時間(指数バックオフ)を設計します。
4. コネクションプールとトランザクションの関係を意識する
コミット/ロールバック忘れでコネクションが返却されると、ロックが残り続けるリスクがあります。フレームワークのトランザクション管理機構を使うか、try-finally で確実に終了させます。
// アプリ層の疑似コード
try {
connection.beginTransaction();
// ... DB操作 ...
connection.commit();
} catch (Exception e) {
connection.rollback();
throw e;
} finally {
connection.close(); // プールへ返却
}
5. 読み取り専用処理はREAD ONLYを宣言する
BEGIN READ ONLY;
SELECT ... ; -- 書き込みロックを取らないのでブロックしにくい
COMMIT;
トランザクション設計は「正常系」だけでなく「異常系(タイムアウト、デッドロック、外部障害)でどう回復するか」まで含めて設計するのが実務品質です。