ACID(アシッド)はリレーショナルデータベースのトランザクションが満たすべき4つの性質を表す頭字語で、Atomicity(原子性)・Consistency(一貫性)・Isolation(独立性)・Durability(永続性)の頭文字を取ったものです。これらの性質を満たすことで、複数のクエリをひとまとめにした処理が部分的失敗や同時実行による不整合を起こさない強整合性を実現できます。
本記事ではACIDの各特性が何を意味するか、DBMS(Database Management System、データベース管理システム。PostgreSQL・MySQL・Oracle のような DB 製品本体を指す)がどのような仕組みで支えているか、そしてACIDの代償としてどんなトレードオフがあるかを整理します。
トランザクションとは
ACIDの話に入る前にトランザクションを押さえます。トランザクションとは「DBに対する複数の操作(クエリ)を1つの論理単位としてまとめたもの」です。
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1; -- A口座から1000円引く
UPDATE accounts SET balance = balance + 1000 WHERE id = 2; -- B口座に1000円足す
COMMIT;
このような送金処理では、片方だけ実行されて片方が失敗すると残高が壊れます。「両方成功するか、両方失敗するか、どちらか」を保証するのがトランザクションの役割で、この保証を支える4特性が ACID です。
Atomicity(原子性)
トランザクション内の全ての操作は all or nothing で扱われます。一部だけ反映されることはなく、途中で失敗したら全ての変更がロールバックされて、トランザクション開始前の状態に戻ります。
たとえば上の送金処理で2つ目のUPDATEがエラーになった場合、1つ目のUPDATE結果も自動的に取り消されます。
DBMSはUNDOログ(PostgreSQLでは行の旧バージョン、MySQL InnoDBではundo log)を使って、ロールバック時に元の値に戻せるようにしています。
Consistency(一貫性)
トランザクションが完了したとき、DBが定義済みの整合性ルールをすべて満たした状態になっていることを指します。
ここで言う整合性ルールは具体的には以下のようなものがあります。
- NOT NULL 制約
- UNIQUE 制約
- 外部キー制約
- CHECK 制約
- ユーザー定義トリガ
たとえば次のようなトランザクションを考えます。
BEGIN;
INSERT INTO orders (user_id) VALUES (999); -- user_id=999 は users テーブルに存在しない
COMMIT;
このINSERTは外部キー制約に違反するため、commit時にエラーとなりトランザクション全体がロールバックされます。結果として、DBは「整合性のあるルールを満たした状態」のまま保たれます。
「途中」と「終わり」を区別する
Consistencyは 「commit時点で整合性が取れていればよい」 という意味であって、トランザクション中ずっと整合性が保たれている必要はありません。
たとえば送金処理の途中、「A口座から1000円を引いた直後 / B口座へ1000円足す前」のスナップショットを切り出すと、システム全体の総残高は1000円不足しています。しかしトランザクション内であればこの中間状態は外から見えず(後述のIsolationによって保証されます)、最終的にcommitした時点で総残高は元に戻っています。
整理すると以下のようになります。
| タイミング | 状態 |
|---|---|
| Before(開始前) | 制約を満たしている |
| 途中(実行中) | 一時的に違反していてもOK |
| After(commit時) | 必ず制約を満たしている |
DBが見るルールとアプリが守るルール
Consistencyは「DB単独で保証できる範囲」と「アプリケーションが書かないと保証できない範囲」の合算で成り立ちます。
- DBが見る制約・トリガは、違反するとDBMSが自動的にロールバックする
- アプリのビジネスルールは、アプリが正しいトランザクションを書く責任で守る
たとえば「送金前後でA・B口座の合計残高は変わらない」というビジネスルールは、外部キー制約やCHECK制約では表現しきれません。「2つのUPDATEを1つのトランザクションにまとめる」とアプリ側が記述して初めて保証されます。
このため、ACIDの中でConsistencyだけは少し性格が違い、「DBMS単独の特性」というよりは 「DB + アプリの協調によって守られる契約」 と理解しておくとよいです。Atomicity / Isolation / Durability がDBMSの仕組みそのものを指すのに対して、Consistencyだけはアプリ側の責任を含む、という点が特徴的です。
Isolation(独立性/分離性)
並行実行される複数のトランザクションが互いに干渉せず、あたかも単独で実行されたかのような結果になることです。
なぜIsolationが必要か
トランザクションが1本ずつ順番に実行されるなら何も問題は起きません。しかし実際のDBでは複数ユーザーの処理が同時に走るため、何の制御もしないと「途中の状態を別の人が読む」「同じ行を2人が同時に書き換える」といった不整合が起きます。
たとえば商品在庫1個に対して、ほぼ同時に2人が注文するケースを考えます。
sequenceDiagram
autonumber
participant T1 as T1(ユーザーAの注文)
participant DB as products
participant T2 as T2(ユーザーBの注文)
T1->>DB: BEGIN
T2->>DB: BEGIN
T1->>DB: SELECT stock WHERE id=1 → 1
T2->>DB: SELECT stock WHERE id=1 → 1
Note over T1,T2: 両者とも stock>0 なので注文OK と判断
T1->>DB: UPDATE stock=stock-1<br/>COMMIT → 0
T2->>DB: UPDATE stock=stock-1<br/>COMMIT → -1(!)
両方のトランザクションが「在庫1」の状態を見て注文を通したため、最終的に在庫がマイナスになりました。これが並行実行による不整合の典型例です。Isolationが正しく効いていれば、後発のトランザクションは先発の結果を反映してから動くはずです。
並行実行で起きる代表的な異常現象
ANSI SQL では Isolation が崩れたときに起きる現象を3種類に整理しています。
Dirty Read: commitされていない変更を読んでしまう
sequenceDiagram
autonumber
participant T1
participant DB as accounts
participant T2
T1->>DB: BEGIN
Note over DB: balance = 1000
T1->>DB: UPDATE balance=0 WHERE id=1
Note over T1: T1内では balance=0、未確定
T2->>DB: BEGIN
T2->>DB: SELECT balance WHERE id=1 → 0
Note over T2: T1の未確定の値が見える
T2->>DB: COMMIT
T1->>DB: ROLLBACK
Note over DB: balance は元の 1000 に戻る
T2が読んだ「0」という残高は、最終的に T1 が ROLLBACK したためDBの公式な状態としては一度も存在しなかった値になります。にもかかわらず T2 はその値を元に判断してしまっており、これが Dirty Read の危険性です。
なお Dirty Read 自体は「commit/rollback が確定する前の値を読む」現象なので、T1 が最終的に COMMIT しようと ROLLBACK しようと発生します。ただし害が明確に出るのは ROLLBACK されたときで、T2 は実在しない値を元に処理を進めたことになります。
Non-repeatable Read: 同じ行を2回読むと値が変わっている
sequenceDiagram
autonumber
participant T1
participant DB as products
participant T2
T1->>DB: BEGIN
T1->>DB: SELECT stock WHERE id=1 → 100
T2->>DB: BEGIN
T2->>DB: UPDATE stock=50 WHERE id=1
T2->>DB: COMMIT
T1->>DB: SELECT stock WHERE id=1 → 50
Note over T1: 変わってる!
T1から見ると、同じトランザクション内で同じ行を2回読んだのに値が違うため、計算ロジックが破綻します(在庫100で計算した金額と在庫50で計算した金額が混在するなど)。
Phantom Read: 同じ条件で2回検索すると行数が変わっている
sequenceDiagram
autonumber
participant T1
participant DB as orders
participant T2
T1->>DB: BEGIN
T1->>DB: SELECT COUNT(*) WHERE status='pending' → 5
T2->>DB: BEGIN
T2->>DB: INSERT INTO orders (status) VALUES ('pending')
T2->>DB: COMMIT
T1->>DB: SELECT COUNT(*) WHERE status='pending' → 6
Note over T1: 増えてる!
集計レポートが集計途中で件数が変わって整合しなくなります。Non-repeatable Read が「同じ行の値が変わる」のに対し、Phantom Read は「行数が変わる」点が違いです。
分離レベルの4段階(ANSI SQL)
これらの異常現象をどこまで防ぐかを決めるのが分離レベルです。レベルが高いほど安全だが遅く、低いほど高速だが危険です。
| 分離レベル | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 起こりうる | 起こりうる | 起こりうる |
| READ COMMITTED | × | 起こりうる | 起こりうる |
| REPEATABLE READ | × | × | 起こりうる |
| SERIALIZABLE | × | × | × |
完全に直列化されたかのような結果になるのは SERIALIZABLE(直列化可能)だけですが、実用上はパフォーマンスとのトレードオフから、より緩い分離レベルが選ばれることが多いです。各分離レベルの実装(Read Committed / スナップショット分離 / 直列化可能性)の内部仕組みは『トランザクション分離レベルの実装詳解 — Read Committed / スナップショット分離 / 直列化可能性』を参照してください。
主要DBMSのデフォルト分離レベル
| DBMS | デフォルト分離レベル |
|---|---|
| PostgreSQL | READ COMMITTED |
| MySQL(InnoDB) | REPEATABLE READ |
| Oracle | READ COMMITTED |
| SQL Server | READ COMMITTED |
Isolationの実装方式
Isolationを実装する方式は大きく2種類あります。
- ロックベース
- 共有ロック・排他ロックでアクセスを制御する方式
- 書き込みが読み込みをブロックする
- MVCC(Multi-Version Concurrency Control)
- 各トランザクションが「自分専用のスナップショット」を見る方式
- 書き込みは新バージョンを作成し、古いバージョンを読み続けるトランザクションをブロックしない
PostgreSQL・Oracle・MySQL InnoDBはMVCCを採用しており、書き込みと読み込みが互いをブロックしないという大きな利点があります。
分離レベルを安易に下げない
「パフォーマンスのために分離レベルを下げる」は典型的なアンチパターンです。たとえば MySQL InnoDB のデフォルトである REPEATABLE READ を READ COMMITTED に下げると、上で見た Non-repeatable Read が発生するようになります。冒頭の在庫管理の例のように、トランザクション中に在庫数が変わって計算が狂い、在庫がマイナスになるといった不整合を引き起こす可能性があります。
そもそも分離レベルを下げたくなる動機は「ロック競合でスループットが落ちる」ことが多いですが、これに対しては局所的な対処が基本です。
SELECT ... FOR UPDATEで必要な行だけ明示的にロックする- 楽観的ロック(version列を使ったCAS)でロックそのものを避ける
- そもそもトランザクション境界を見直し、ロック保持時間を短くする
分離レベルは「トランザクション全体の整合性のセーフティネット」なので、これを下げるのは最後の手段にすべきです(『失敗から学ぶRDBの正しい歩き方』第14章)。
Durability(永続性)
トランザクションがcommitされたら、その変更はシステム障害(クラッシュ・電源断)が起きても失われません。
これを実現する中心的な仕組みが WAL(Write-Ahead Logging、ログ先行書き込み) です。データ本体の更新前に「これからこの変更をする」というログをディスクに書き、ログの永続化が確認できてからcommitを返します。クラッシュ後はログを再生して未反映の変更を復元します。
DBMSは「commitが返ったら変更は永続化されている」という契約を、以下のような仕組みで支えています。
- WAL(PostgreSQL)/redoログ(MySQL InnoDB / Oracle)/トランザクションログ(SQL Server)
fsyncによるディスクへの強制書き込み- レプリケーション(同期・準同期・非同期)
fsyncとは
fsync はOSのシステムコールで、ファイルの内容を本当にディスクに書き込ませるための命令です。
通常のファイル書き込み(write)は、OSのページキャッシュ(メモリ)にデータを置くだけで、実際のディスクへの書き出しはOSが後でまとめて行います。これは速いですが、書いた直後にクラッシュ・電源断が起きるとキャッシュ上のデータは失われます。
そこで fsync を呼ぶと、OSは対象ファイルのページキャッシュをディスクに強制フラッシュし、書き込みが物理的に完了するまで戻ってきません。DBMSはWALを書いた後に fsync を呼ぶことで「ログが確実にディスクに残った」ことを確認し、それからcommitを返します。
fsync をどのタイミングで呼ぶかは性能と耐久性のトレードオフです。毎commitごとに fsync するとレイテンシが伸びるため、たとえば MySQL InnoDB の innodb_flush_log_at_trx_commit パラメータでこのトレードオフを直接コントロールできます。commitごとにfsyncするか、1秒に1回まとめるか、などを設定でき、後者は最大1秒分のcommitを失う可能性と引き換えに高速化できます。
ACIDのトレードオフ
データ整合性を保つためにはトランザクション、ひいてはACIDの保証が必須です。Atomicity(全部成功か全部失敗)や Isolation(互いに干渉しない)が壊れると整合性は維持できません。一方でACIDには以下のコストがあります。
- ロック・スナップショット保持のオーバーヘッドによりスループットが下がる
- commitごとのfsyncによりレイテンシが伸びる
- 複数ノードにまたがるトランザクションを支える仕組み(2フェーズコミット)を使うと、台数が増えるほど応答が遅くなる
最初の2つは「同じノード内」のコストで、これは1台のDBで完結する範囲なら大した問題にはなりません。問題は3つ目です。
ノードを跨ぐと急にコストが跳ね上がる
1ノード内のトランザクションは、WALを書いてfsyncすれば終わるため比較的安価です。ところがトランザクションが複数ノードに跨った瞬間、2フェーズコミットが必要になりコストが跳ね上がります。
- PrepareとCommitの2フェーズで通信往復が発生する
- 全ノードがロックを保持したまま全員の応答を待つ
- コーディネータが落ちると参加ノードが宙ぶらりんになる
- ノード数に比例してレイテンシが伸びる
つまり「データ整合性を維持しようとするとトランザクションが必要、でもトランザクションをノード跨ぎでやると一気に重くなる」というジレンマがあり、これがRDBが水平分散(スケールアウト)に弱い根本原因です。
整合性とスケーラビリティの選択
このジレンマに対しては大きく2つの逃げ道があります。
- シャーディングで「1トランザクション = 1ノード内」に収める
- ACIDを保ったままスケールアウトできる
- ただしデータ分割境界の設計が難しく、ノードを跨ぐ処理は諦めるか別途整合性を取る必要がある
- ACIDを緩めて結果整合性で済ませる
- NoSQLの多くが選んだ路線
- BASE(Basically Available、Soft-state、Eventual Consistency)という弱い保証に切り替えることで、ノード数を増やせばスループットも線形に伸ばせる
ACID と BASE の対比は 結果整合性とBASE特性: 強整合性とのトレードオフと調整可能整合性(W+R>N)、CAP定理側は CAP定理(CA/CP/AP)とPACELC定理(PA/EL・PA/EC・PC/EC)による分散データベース分類 を参照してください。RDBとNoSQLの位置づけ整理は『RDB技術者のためのNoSQLガイド』第3章「データベースの中のNoSQLの位置づけ」が体系的でわかりやすいです。
シャーディング戦略の詳細(レンジ / ハッシュ / リバランシング / ルーティング)は『シャーディング / パーティショニング戦略: レンジ・ハッシュ・リバランシング・ルーティング』を参照してください。
まとめ
ACID は RDB のトランザクションが満たすべき4特性で、それぞれを一言で言うと以下のとおりです。
- Atomicity(原子性)はトランザクションがall or nothingで扱われ、途中で失敗したら全ての変更が取り消されること
- Consistency(一貫性)はトランザクション完了時にDBが定義済みの整合性ルール(制約・トリガ)をすべて満たしていること
- Isolation(独立性)は並行実行される複数のトランザクションが互いに干渉せず、あたかも単独で実行されたかのような結果になること
- Durability(永続性)はcommit済みの変更が障害(クラッシュ・電源断)が起きても失われないこと
データ整合性を保つにはトランザクション(≒ACID)が必須ですが、トランザクションがノードを跨ぐと2フェーズコミットのコストが急に跳ね上がり、これがRDBの水平分散の天井になっています。NoSQLの多くはACIDを緩めて BASE に切り替えることで、この天井を外してスケーラビリティを獲得しています。