ACID特性を「欠けたら何が壊れるか」で理解する
はじめに — トランザクションを「使っている」だけでは足りない
ある日、ECサイトの売上レポートに異常な数字が現れました。同じ商品の在庫数が「-1」になっています。調査すると、2人のユーザーがほぼ同時に「残り1個」の商品を購入し、両方の注文が成功していました。開発者はトランザクションを使っていたのに、なぜこんなことが起きたのでしょうか。
答えは、トランザクションの**Isolation(分離性)**の理解不足にありました。私たちが日常的に使っているBEGIN/COMMITの裏側では、ACID特性という4つの保証が動いています。この記事では、その1つ1つを「もし欠けたら何が壊れるのか」という視点で解説します。
この記事の対象読者: SQLの基本(SELECT, INSERT, UPDATE)とトランザクション(BEGIN/COMMIT/ROLLBACK)の基本的な使い方を知っている方。「BEGIN/COMMITは書くけど、Isolationって何?」くらいのレベルの方に最適です。
この記事で学べること:
- ACID特性の4つの保証を「定義 → なぜ重要か → なかったらどうなるか → 実例」の構造で理解する
- 各特性が実際のトランザクション処理でどう連携するかを把握する
- 分離レベルの選び方や排他制御の実践的な判断力を身につける
ACIDとは何か — トランザクションが守る4つの約束
ACIDは、データベース(以下、DB)トランザクションが信頼できる処理を実現するために保証する4つの特性の頭文字です。 この4つの特性がそろって初めて、「トランザクションは信頼できる」と言えます。
図: ACID 4特性の概要図
4つの特性を一言で表すと、次のようになります。
- Atomicity(原子性): 全部成功するか、全部なかったことにするか — 中途半端な状態にならない
- Consistency(整合性): トランザクション前後でデータの整合性ルール(制約)が壊れない
- Isolation(分離性): 同時に実行される複数のトランザクションが互いに干渉しない
- Durability(永続性): コミットされたデータは、障害が起きても失われない
これから各特性を順番に掘り下げていきます。それぞれ「定義 → なぜ重要か → なかったらどうなるか → 実例」の構造で解説するので、気になる特性から読んでも大丈夫です。
歴史的背景
ACIDという用語は、1983年にTheo HaerderとAndreas Reuterが論文「Principles of Transaction-Oriented Database Recovery」(ACM Computing Surveys, Vol. 15, No. 4)で初めて定義しました。その土台となったのは、Jim Grayが1981年にVLDBで発表した「The Transaction Concept: Virtues and Limitations」です。Grayはトランザクションを「原子性・永続性・整合性を持つ状態変換」として形式化しましたが、ACIDという名前はまだ使っていませんでした。Haerder & Reuterが4つの特性に名前をつけ、頭文字で呼ぶようにしたのです。
Atomicity(原子性)— 「全部か、ゼロか」
定義
トランザクション内の操作は「すべて成功」か「すべて失敗(ロールバック)」のどちらかになります。途中で一部だけ適用された中途半端な状態は発生しません。
なぜ重要か
銀行の送金を考えてみます。口座Aから10万円を引き落とし、口座Bに10万円を入金する。この2つの操作は「セット」でなければ意味がありません。片方だけ適用されると、10万円が消えるか、根拠なく増えてしまいます。
Atomicityは、こうした「複数テーブルにまたがる更新を安全に1つの単位として扱う」ための保証です。
図: 銀行送金トランザクションのフロー。引き落としと入金の各ステップで失敗するとROLLBACKされ、成功すればCOMMITされる
なかったらどうなるか — 障害シナリオ
送金処理の途中でサーバーがクラッシュしたとします。口座Aから10万円が引き落とされたのに、口座Bへの入金はまだ実行されていません。Atomicityがなければ、この「中途半端な状態」がそのまま残ります。10万円が消失した状態です。
SQLコード例
BEGIN;
-- 口座Aから10万円引き落とし
UPDATE accounts SET balance = balance - 100000 WHERE id = 'A';
-- 口座Bに10万円入金
UPDATE accounts SET balance = balance + 100000 WHERE id = 'B';
-- すべて成功したらコミット
COMMIT;
-- 途中でエラーが発生した場合は自動的にロールバックされる
-- 明示的にロールバックする場合:
-- ROLLBACK;
このトランザクション内で何らかのエラーが発生した場合、引き落としと入金の両方が取り消されます。「片方だけ適用された」という状態にはなりません。
DBはどう実現しているか — WAL
DBは**WAL(Write-Ahead Logging)**という仕組みでAtomicityを実現しています。データ本体を更新する前に、まず「何を変更するか」をログファイルに記録します。このログがあるおかげで、クラッシュが発生しても、ログを見て「この変更は未完了だから取り消す」「この変更はコミット済みだから適用する」という判断ができます。WALについてはDurabilityの章でもう少し詳しく説明します。
Sagaパターンとの関係
モノリスなアプリケーションでは、1つのDB内でAtomicityを保証できます。しかしマイクロサービスでは、複数のサービスが別々のDBを持つため、単一のトランザクションでは対応できません。この問題を解決するのがSagaパターンです。Sagaパターンでは、各サービスのローカルトランザクションを順次実行し、失敗した場合は補償トランザクション(取り消し処理)を実行して整合性を回復します。
次に、トランザクションが守るべき「データの約束事」について見ていきます。
Consistency(整合性)— データの「約束」を破らせない
定義
トランザクションの実行前後で、DBに定義されたすべての**整合性ルール(制約)**が満たされた状態を保ちます。
「整合性ルール」とは何か
DBにおける「約束事」は、制約として定義されます。
- NOT NULL制約: この列には必ず値が入っている必要があります
- UNIQUE制約: この列の値は重複してはいけません
- 外部キー制約: 参照先のレコードが存在している必要があります
-
CHECK制約: 指定した条件を満たす必要があります(例:
balance >= 0)
これらの制約が「データの約束事」であり、Consistencyはこの約束が壊れないことを保証します。
なぜ重要か
不正なデータがDBに入ると、後続のすべての処理が信頼できなくなります。データの腐敗は発見が遅れるほど被害が拡大します。最初の1件の不正データが、関連するテーブルへと波及し、気づいたときには大量のデータが汚染されていた、というのはよくある話です。
なかったらどうなるか — 障害シナリオ
ユーザー登録時にメールアドレスのUNIQUE制約がなかったらどうなるでしょうか。同じメールアドレスで複数のアカウントが作成されます。パスワードリセットのメールを送ろうとしても、どのアカウントに紐づくのか不明です。ログイン時にどのアカウントとして認証すべきかも判断できません。
SQLコード例
-- 外部キー制約に違反するINSERT → トランザクションが中断される
BEGIN;
-- 存在しないuser_id = 999を参照する注文を作成しようとする
INSERT INTO orders (user_id, product_id, quantity)
VALUES (999, 1, 2);
-- ERROR: insert or update on table "orders" violates foreign key constraint
-- "orders_user_id_fkey"
-- エラーにより、このトランザクションはロールバックされる
ROLLBACK;
-- CHECK制約で残高がマイナスにならないことを保証する
CREATE TABLE accounts (
id VARCHAR(10) PRIMARY KEY,
balance INTEGER NOT NULL CHECK (balance >= 0)
);
BEGIN;
-- 残高が足りない場合、CHECK制約が違反を検出する
UPDATE accounts SET balance = balance - 100000 WHERE id = 'A';
-- ERROR: new row for relation "accounts" violates check constraint
-- "accounts_balance_check"
ROLLBACK;
制約が定義されていれば、不正なデータの挿入/更新はDBが自動的に拒否します。
DBの制約 vs アプリケーションの整合性
ここで注意が必要なのは、ACIDの「C」が保証するのはDBレベルの制約のみという点です。
DB制約(NOT NULL, UNIQUE, 外部キー, CHECK)はDBが自動的に強制します。違反すれば即座にエラーです。一方で、「注文合計金額 = 各商品の単価 × 数量の合計」のようなビジネスルールは、アプリケーション側で保証する必要があります。
とはいえ、アプリケーション側のビジネスロジックもトランザクション内で実行することで、他の特性(特にAtomicityとIsolation)の保護を受けることができます。
他の特性との関係
Consistencyは他の3つの特性と密接に関係しています。
- Atomicityがなければ: トランザクションが途中で止まると、制約違反の中途半端な状態が残りえます
- Isolationがなければ: 他のトランザクションが整合性チェックの途中のデータを読んでしまう可能性があります
つまり、Consistency単独では成立しません。他の特性と連携して初めて、データの整合性が維持されます。
Isolation(分離性)— 「同時実行」の落とし穴
ACID特性の中で最も複雑で、最も実務で問題を起こしやすいのがIsolationです。冒頭の在庫マイナス問題の答えもここにあります。
定義
複数のトランザクションが同時に実行されても、それぞれのトランザクションは他のトランザクションの途中状態を見ることなく、あたかも1つずつ順番に実行されたかのように振る舞います。
なぜ重要か
現実のDBでは、多数のトランザクションが同時に実行されます。分離性がなければ、他のトランザクションの途中の書き込みを読んでしまい、不正確なデータに基づいて処理が進みます。
なかったらどうなるか — 3つの典型的な異常現象
分離性が不完全な場合に発生する代表的な異常現象が3つあります。
図: 3つの分離性異常現象の比較
1. Dirty Read(ダーティリード)
まだCOMMITされていない他のトランザクションの変更を読んでしまう現象です。そのトランザクションがROLLBACKされると、存在しないデータに基づいて処理したことになります。
2. Non-Repeatable Read(反復不能読み取り)
同じトランザクション内で同じSELECTを2回実行すると、別の結果が返ってくる現象です。間に他のトランザクションがUPDATE → COMMITを実行したために、値が変わっています。
3. Phantom Read(ファントムリード)
同じ条件のSELECTを2回実行すると、1回目には存在しなかった行が2回目に現れる現象です。他のトランザクションがINSERTしたためです。「幽霊(phantom)」のように行が出現することからこの名前がついています。
SQLコード例 — Dirty Readが発生するシナリオ
以下は、2つのセッション(トランザクションA, B)の実行を時系列で示したものです。
-- === セッションA ===
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 'A';
-- この時点ではまだCOMMITしていない
-- === セッションB(別の接続)===
BEGIN;
-- 分離レベルがRead Uncommittedの場合、
-- セッションAのCOMMIT前の変更を読めてしまう
SELECT balance FROM accounts WHERE id = 'A';
-- → 0(まだCOMMITされていないのに!)
-- セッションBはこの値を基に処理を続行する...
-- === セッションA ===
ROLLBACK; -- やっぱり取り消し!
-- balanceは元の値に戻るが、セッションBは「0」を基に処理を進めてしまった
セッションBは、存在しなかったことにされたデータを基に処理を進めてしまいました。これがDirty Readの危険性です。
分離レベル — 性能と安全性のトレードオフ
完全なIsolation(Serializable)は安全ですが遅い。そこで、多くのDBはデフォルトで完全ではない分離レベルを使っています。この選択を理解しないと、冒頭のような「トランザクションを使っているのにおかしい」という事態になります。
4つの分離レベル
SQL標準は、防げる異常現象の範囲に応じて4段階の分離レベルを定義しています。
| 分離レベル | Dirty Read | Non-Repeatable Read | Phantom Read | 性能 |
|---|---|---|---|---|
| Read Uncommitted | 発生する | 発生する | 発生する | 最速 |
| Read Committed | 防げる | 発生する | 発生する | 速い |
| Repeatable Read | 防げる | 防げる | 発生する | やや遅い |
| Serializable | 防げる | 防げる | 防げる | 最も遅い |
下に行くほど安全ですが、その分だけ同時実行性が下がり、処理が遅くなります。
各DBのデフォルト分離レベル
ここで重要なのは、ほとんどのDBのデフォルトがSerializableではないという点です。
| RDBMS | デフォルト分離レベル |
|---|---|
| PostgreSQL | Read Committed |
| MySQL (InnoDB) | Repeatable Read |
| SQL Server | Read Committed |
| Oracle | Read Committed |
多くの開発者は分離レベルを意識せずにデフォルトのまま使っています。そのデフォルトがSerializableではないということは、Phantom ReadやNon-Repeatable Readが発生しうるということです。
冒頭の在庫問題を解き明かす
冒頭のECサイトで在庫がマイナスになった原因を見てみます。
-- ユーザーA と ユーザーB が「ほぼ同時に」実行
-- === ユーザーA ===
BEGIN;
SELECT stock FROM products WHERE id = 1;
-- → stock = 1(残り1個!)
-- 「在庫あり」と判定し、購入処理に進む
-- === ユーザーB(ほぼ同時に)===
BEGIN;
SELECT stock FROM products WHERE id = 1;
-- → stock = 1(ユーザーAの更新はまだCOMMITされていない)
-- 「在庫あり」と判定し、購入処理に進む
-- === ユーザーA ===
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- stock = 0 になった
-- === ユーザーB ===
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- stock = -1 になってしまった!
デフォルトの分離レベル(Read CommittedやRepeatable Read)では、通常のSELECTを使う限りこの問題が発生します。ユーザーBのSELECT時点でユーザーAの変更はまだCOMMITされていないため、stock = 1が返ります。両方が「在庫あり」と判定し、両方が在庫を減らした結果、在庫がマイナスになりました。
解決策: SELECT ... FOR UPDATE
Serializable分離レベルにすればこの問題は防げますが、全トランザクションの性能に影響します。より実用的な解決策は、**SELECT ... FOR UPDATE**で必要な行だけをロックする方法です。
BEGIN;
-- FOR UPDATEで行ロックを取得
-- 他のトランザクションは、この行のロックが解放されるまで待機する
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- → stock = 1
-- 在庫チェック(アプリケーション側)
-- stock >= 1 を確認
-- 在庫を減らす
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- ロックが解放される
FOR UPDATEを付けることで、ユーザーAがこのSELECTを実行している間、ユーザーBの同じ行に対するSELECT ... FOR UPDATEはブロックされます。ユーザーAがCOMMITした後にユーザーBのSELECTが実行され、その時点ではstock = 0が返るため、購入処理は正しく拒否されます。
楽観的ロックという選択肢
SELECT ... FOR UPDATEは悲観的ロック(競合が起きることを前提にロックを取る)です。一方、楽観的ロック(競合は稀だと想定し、更新時にチェックする)というアプローチもあります。
-- 読み取り時: バージョン番号を取得
SELECT id, stock, version FROM products WHERE id = 1;
-- → stock = 10, version = 5
-- 更新時: バージョン番号を条件に含める
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- affected_rows = 0 の場合
-- → 別のトランザクションが先に更新した
-- → アプリケーション側でリトライ
| シナリオ | 推奨 | 理由 |
|---|---|---|
| 在庫管理(残数が少ない商品) | 悲観的ロック | 競合頻度が高く、正確性が最重要 |
| 座席予約 | 悲観的ロック | 同一座席への同時アクセスが予想される |
| ユーザープロフィール更新 | 楽観的ロック | 同一ユーザーの同時編集は稀 |
| CMSの記事編集 | 楽観的ロック | 同一記事の同時編集は稀 |
実務では、ほとんどのケースでデフォルトの分離レベル(Read Committed)で十分です。競合が起きやすい処理(在庫管理、座席予約、残高更新等)では、SELECT ... FOR UPDATEや楽観的ロックで個別に対応するのが一般的です。
Durability(永続性)— コミットしたら、もう消えない
定義
一度COMMITされたトランザクションの結果は、その後にシステム障害(停電、クラッシュ、ハードウェア故障等)が発生しても失われません。
なぜ重要か
「保存したはずのデータが消えた」が起きたら、そのDBは信頼できません。銀行の入金処理で「コミット成功」とレスポンスを返した後に停電でデータが消えたら致命的です。ユーザーに「完了しました」と伝えた時点で、そのデータは絶対に消えてはならないのです。
なかったらどうなるか — 障害シナリオ
ユーザーに「注文完了」画面を表示した直後にサーバーがクラッシュしたとします。再起動後にDBを確認すると、注文データが存在しません。ユーザーは「注文した」と認識しているのに、システム上は注文が存在しない。この食い違いは、カスタマーサポートへのクレーム、二重注文、信頼の喪失につながります。
DBはどう実現しているか
図: COMMITからディスク永続化までのフロー
Durabilityの実現には、3つの仕組みが連携しています。
WAL(Write-Ahead Logging)
Atomicityの章でも触れたWALは、Durabilityの核心でもあります。データ本体を更新する前に、ログファイルに変更内容を書き込みます。クラッシュ後はこのログを再生(replay)してデータを復元します。
WALファイルへの書き込みは**シーケンシャル(連続的)**に行われます。データファイルへのランダム書き込みと比べてはるかに高速なため、性能面でも有利です。
fsync
通常、OSはファイル書き込みをメモリ上のバッファに一時的に溜めてから、まとめてディスクに書き出します。しかしこのバッファにある間に電源が落ちると、書き込んだつもりのデータが消えてしまいます。
fsyncは、OSのバッファを経由せずに物理ディスクに確実に書き込むシステムコールです。COMMITの時点でfsyncを実行することで、ディスクへの書き込みを保証します。
チェックポイント
定期的にメモリ上の変更をデータファイルに反映する処理です。チェックポイントにより、リカバリに必要なログの量が減り、クラッシュ時の復旧が速くなります。
重要なのは、データファイルへの書き込みはCOMMIT後の非同期処理だという点です。COMMITの完了はWALへのfsyncが成功した時点で返されます。データファイルへの反映は後からゆっくり行われます。
性能とのトレードオフ
fsyncは安全ですが遅い操作です。PostgreSQLではsynchronous_commit = offに設定することで、fsyncを待たずにCOMMITを返すことができます。スループットは大幅に向上しますが、クラッシュ時に直近のコミットが失われるリスクがあります。
PostgreSQLの公式ドキュメントでは、この設定について「非同期コミットは、fsyncを無効にすることで得られる性能向上の大部分を、データ破損リスクなしに提供する」と説明しています。分析パイプラインやキャッシュレイヤーなど、一時的なデータ損失が許容されるシステムでは有効な選択肢です。ただし、金融データや注文処理のような「絶対に失ってはならない」データに対しては、デフォルトの同期コミットを維持すべきです。
ここまで4つの特性を個別に見てきました。次に、これらが実際のトランザクションでどう連携するかを見てみます。
4つの特性はどう連携するのか — ACIDの全体像
ここまで個別に解説した4つの特性は、実際のトランザクションではどう連携するのでしょうか。4つの特性は独立しているようで、実は相互に依存しています。 Atomicityなしに整合性は維持できませんし、Durabilityなしに信頼性は成り立ちません。
ECサイトの注文処理を1つのトランザクションで実行する具体例で見てみます。
図: ECサイト注文処理とACID特性の対応図
-- ECサイトの注文処理 — ACID特性が連携する完全な例
BEGIN;
-- ステップ1: 在庫確認 + 行ロック 【Isolation】
-- FOR UPDATEで行ロックを取得し、他のトランザクションの干渉を防ぐ
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- → stock = 5
-- ステップ2: ビジネスルールのチェック 【Consistency】
-- 在庫 >= 注文数 をアプリケーション側で確認
-- (CHECK制約 balance >= 0 も併用)
-- ステップ3: 在庫減算 【Atomicityが保護】
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- ステップ4: 注文作成 【Atomicityが保護】
INSERT INTO orders (user_id, product_id, quantity, created_at)
VALUES (42, 1, 1, NOW());
-- ステップ5: コミット 【Durability】
-- WAL + fsyncでディスクに永続化。この後に停電が起きても注文は消えない
COMMIT;
各ステップとACID特性の対応を整理します。
- ステップ3と4の間でクラッシュしたら → Atomicity: 在庫減算と注文作成の両方がROLLBACKされ、在庫は元に戻る
- stock < 0 にならないことの保証 → Consistency: CHECK制約とFOR UPDATEによる排他制御の組み合わせ
- 別ユーザーの同時購入との競合 → Isolation: FOR UPDATEが行ロックを取得し、他のトランザクションは待機する
- COMMITが完了した後のサーバー停止 → Durability: WAL + fsyncにより、注文データは消えない
このように、1つのトランザクションの中で4つの特性が連携して動いています。1つでも欠ければ、何かしらの問題が発生する可能性があります。
よくある誤解と注意点
ACID特性に関して、実務でよく見かける誤解を整理します。
誤解1: 「トランザクションを使えばACIDが完全に保証される」
前述のとおり、分離レベルのデフォルトがSerializableでないDBがほとんどです。トランザクションを使うだけではPhantom ReadやNon-Repeatable Readは防げません。分離レベルを意識し、必要に応じてFOR UPDATEや楽観的ロックで対策する必要があります。
Stanford大学のTodd WarszawskiとPeter Bailisが2017年のSIGMODで発表したACIDRain研究は、12のECアプリケーションを調査し、22の重大な攻撃パターンを発見しました。そのうち5つは、より強い分離レベルで防げるものでした。注目すべきは、SELECT FOR UPDATEによる保護を試みたアプリケーションの3分の1しか正しく実装できていなかったという点です。
誤解2: 「ConsistencyはDBが全部面倒を見てくれる」
DB制約で保証できるのはNOT NULL, UNIQUE, 外部キー, CHECK等のみです。「在庫 >= 注文数」「注文合計金額 = 各商品の単価 × 数量の合計」のようなビジネスルールはアプリケーションが責任を持つ必要があります。ACIDのCはDB制約を指しますが、ビジネスロジックの整合性もトランザクション内で保つべきです。
誤解3: 「NoSQLにはACIDがない」
これは過去の認識です。MongoDB 4.0(2018年)でマルチドキュメントトランザクションが導入され、DynamoDBも2018年にトランザクションAPIを追加しました。ただし制限もあります。MongoDBはトランザクションの実行時間上限がデフォルト60秒で、4.2以降では個々のoplogエントリが16MB以内という制約があります。DynamoDBは1トランザクションあたり100アイテム/4MB以内で、コストが通常の2倍かかります。
さらに、CockroachDB、TiDB、Google Cloud Spanner、YugabyteDB等の分散SQLはフルSQL + ACID + 水平スケーラビリティを提供しています。特にCockroachDBはデフォルトでSerializable分離レベルを採用しており、最も強い一貫性保証を提供しています。
誤解4: 「ACIDは分散システムでもそのまま適用できる」
単一DBのACIDとマイクロサービスの分散トランザクションは別の問題です。複数のDB間でACIDを実現するには、2PC(Two-Phase Commit)やSagaパターン等の別の仕組みが必要です。分散システムにおけるトランザクション管理は、それだけで1つの大きなテーマです。
実務での判断指針
- まずはDBの制約を最大限活用する: NOT NULL, UNIQUE, 外部キー, CHECKを適切に設定する。アプリケーションコードだけに頼らない
- 分離レベルはデフォルトから始める: ほとんどのケースではRead Committedで十分。問題が出てから調整する
-
排他制御が必要な箇所を特定する: 在庫管理、座席予約、残高更新など、競合が起きやすい処理には
SELECT ... FOR UPDATEや楽観的ロックで個別に対応する
まとめ — ACID特性を理解してトランザクションを「使いこなす」
ACIDは「トランザクションを信頼できるものにする」ための4つの保証です。BEGIN/COMMITの裏にあるこの仕組みを理解することで、適切なトランザクション設計ができるようになります。
4つの特性を振り返ります。
- Atomicity: 全部成功か全部失敗 — 中途半端は許さない
- Consistency: データの約束(制約)を守り続ける
- Isolation: 同時実行でもお互いに干渉しない
- Durability: コミットしたデータは障害でも消えない
実務で最も気をつけるべきはIsolation(分離レベル)です。 デフォルトの分離レベルでは防げない問題が存在することを認識し、必要に応じてFOR UPDATEや楽観的ロックで対処することが、データの信頼性を守る鍵になります。
関連トピック
ACIDの理解を土台にして、以下のトピックへと学びを広げることができます。
- Sagaパターン: マイクロサービスでACIDの境界を越える方法
- 2PC(Two-Phase Commit): 分散トランザクションでACIDを保つ試み
- 冪等性設計パターン: リトライ時の安全性を確保する
- Outboxパターン: DB更新とイベント発行のAtomicityを保証する
Discussion