- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理
- DB保存今ここ
前回、読んでから書く間に別の書き込みが割り込むと更新が消えることを見ました。今回は「無いことを確かめてから入れる」で同じ隙間を見て、隙間を閉じる宣言を扱います。
まず結論
「同じメールアドレスが無いか確かめてから INSERT する」を2つの接続で同時に走らせると、制約を宣言していない表には同じ行が2つ入ります。どちらのトランザクションも正しくコミットした結果であり、データベースは「一つしかない」ことを知らされていないので止めません。
UNIQUE を宣言すると、確かめる役目は INSERT の時点のデータベースへ移り、後から来た側は拒否されます。ACID の C(一貫性)の意味は PostgreSQL と MySQL の公式文書で書き方が違いますが、少なくとも一意性は、宣言した分しか守られません。
PostgreSQL 18(既定設定)の users 表の email 列には、何の制約も宣言していません。2つの接続 A と B が1秒ずらして、それぞれ「BEGIN → 同じ email の行が無いか SELECT で確かめる → 無ければ INSERT → COMMIT」を実行します。最後に表には何行あるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
確かめる文と、入れる文は、別の文
「同じメールアドレスが無いか SELECT で確かめ、無ければ INSERT する」。登録処理の定番です。1人ずつなら重複は生まれません。
ところが SELECT と INSERT は別々の文です。1つのトランザクションに入れても、2つの文の間に別の接続の INSERT が入り込めます。 原子性が保証するのは「自分のトランザクションが全部か無しか」であって、他人がその間に何をするかではありません。これは前回と同じ隙間です。
では、両方とも「無い」を見てから入れたら、何が止めるのか。データベースの側から見ると、止める理由がありません。 email が一つであるべきだとは、誰も宣言していないからです。PostgreSQL の文書は表についてこう書きます。
Furthermore, SQL does not assign unique identifiers to rows, so it is possible to have several completely identical rows in a table. This is a consequence of the mathematical model that underlies SQL but is usually not desirable.
SQL の表は、放っておけば同じ行を何個でも持てます。文書は望ましくないと書き、対処は同じ章の後の節で扱うと続けます。
- ・確かめの結果は、どちらの場合も A・B とも「無い」
- ・違いは INSERT の時点でデータベースが値を照合するかどうか
PostgreSQL 18 で2本同時に走らせる
各接続は BEGIN → SELECT count(*) = 0 AS absent FROM users WHERE email = 'kazu@example.com' → 2秒待つ → absent なら INSERT → 2秒待つ → COMMIT を実行します。B は A の1秒後に始めます。既定の READ COMMITTED で各2回、2回とも同じ結果でした。
まず制約なし(CREATE TABLE users (email text, name text))の B です。
B checked: absent = tINSERT INTO users VALUES ('kazu@example.com', 'B');INSERT 0 1Time: 0.513 msCOMMIT;COMMITTime: 1.431 ms--- final ---email | name------------------+------kazu@example.com | Akazu@example.com | B(2 rows)
B の INSERT は 0.5 ms で通り、同じ email が2行残りました。分離レベルを REPEATABLE READ にしても2回とも2行でした(SERIALIZABLE は試していません)。2つのトランザクションはどちらも正しく、全部か無しかでコミットしています。 原子性の失敗ではありません。
次に email text UNIQUE と宣言した場合です。
B checked: absent = tINSERT INTO users VALUES ('kazu@example.com', 'B');psql:/tmp/b.sql:7: ERROR: duplicate key value violates unique constraint "users_email_key"DETAIL: Key (email)=(kazu@example.com) already exists.Time: 983.227 msSELECT pg_sleep(2);psql:/tmp/b.sql:9: ERROR: current transaction is aborted, commands ignored until end of transaction blockTime: 0.121 msCOMMIT;ROLLBACKTime: 0.112 ms--- final ---email | name------------------+------kazu@example.com | A(1 row)
1行目は制約なしと同じく absent = t です。確かめは今回も「無い」と答えています。 違いは INSERT で、983 ms 待ってから拒否されました(2回目は 992 ms)。B が INSERT したのは t≈3、A がコミットしたのは t≈4 なので、A のコミットを待っていた計算です。B の COMMIT は ROLLBACK と報告されました。
待った理由は、PostgreSQL の索引の実装者向けの章(第63章)に書かれています。
If a conflicting row has been inserted by an as-yet-uncommitted transaction, the would-be inserter must wait to see if that transaction commits. If it rolls back then there is no conflict.
A が巻き戻していれば B は通ったはずです(この分岐は実行していません)。
email 列に UNIQUE を宣言して同じ実験をしたとき、制約なしの場合と比べて B の側で変わったのはどこですか。
宣言は、確かめる役目をデータベースへ移す
UNIQUE を宣言すると何が起きるのか。文書はこう書きます。
Adding a unique constraint will automatically create a unique B-tree index on the column or group of columns listed in the constraint.
一意な索引ができ、INSERT は索引へ項目を入れる操作の一部として同じ値を照合し、重複していれば文を失敗させます。第63章は、照合が索引項目の挿入と一体でなければ there is no obvious way to avoid race conditions と書きます。確かめる操作と入れる操作が、データベースの中の1つの操作になります。 アプリの SELECT は、画面に親切な文言を出すための下見に役目が変わります。
守られるのは、宣言した分だけです。PG の文書は By default, two null values are not considered equal in this comparison. と書き、実機でも UNIQUE の列に NULL を2回入れると2行になりました。UNIQUE NULLS NOT DISTINCT(PostgreSQL 15 から)と宣言し直すと、2回目が拒否されました。
ここで、ACID の最後の文字 C に戻ります。C の意味は、公式文書どうしで一致していません。
PostgreSQL 18 用語集 The property that the data in the database is always incompliance with integrity constraints.MySQL 8.4 §17.2 The consistency aspect of the ACID model mainly involvesinternal InnoDB processing to protect data from crashes.Gray 1981 Transactions preserve the system consistency constraints …
PG の用語集の意味で読むなら、C が守るのは宣言された整合性制約です。宣言の無い email 列には、守るべき制約がそもそもありません。MySQL の文書の意味で読むなら、C はクラッシュからの保護の話で、一意性は別の場所(A UNIQUE index creates a constraint such that all values in the index must be distinct.)で扱われます。Gray 1981 は、トランザクションが保つものを system consistency constraints と呼びます。どれを採っても、「重複しない」はデータベースが自分で思いつく性質ではありません。
Codd 1970 は関係を表す配列(表)の性質として (3) All rows are distinct. と書きました。PG の「同一行を持てる」と矛盾するのではなく、対象が違います。Codd が述べたのは彼の関係モデルの関係を表す配列、PG が述べたのは SQL の表です。SQL の表では、一意性は宣言して初めて付きます。
ファイルでも、確かめてから作ると隙間が残る
同じ形はデータベースの外にもあります。Linux でプログラムがファイルを扱うときは、OS の関数(システムコール)を呼びます。access(2) はファイルの有無や権限を確かめ、open(2) はファイルを開きます。
「確かめてから開く」の危うさを、access(2) の man ページ自身が警告しています。
Warning: Using these calls to check if a user is authorized to, for example, open a file before actually doing so using open(2) creates a security hole, because the user might exploit the short time interval between checking and opening the file to manipulate it.
警告しているのは権限の確認と open の間です。存在の確認そのものを名指ししてはいません。ただ構造は同じで、確かめる呼び出しと使う呼び出しの間に短い時間があり、そこを突かれます。一般に TOCTOU(time-of-check to time-of-use)と呼ばれる形ですが、この語は man ページには出てきません。
「無ければ作る」を隙間なく行う手段は open(2) にあります。
O_EXCL Ensure that this call creates the file: if this flag is specified in conjunction with O_CREAT, and path already exists, then open() fails with the error EEXIST.
確かめと作成を1回の呼び出しに入れ、既にあれば失敗させます。UNIQUE と同じ設計です。しかも man ページは、NFS で O_EXCL が支えられない環境ではこれに頼るロックに a race condition が残ると書きます。宣言が効くのは、その下の実装が約束を支えているときだけです。
Linux でファイルを「まだ無ければ作る」とき、確かめる操作と作る操作の間の隙間を無くせるのはどれですか。
データベースの UNIQUE
アプリの SELECT で「無い」を確かめても、INSERT までの間に別の接続が同じ値を入れられます。UNIQUE を宣言すると一意な索引ができ、INSERT の時点でデータベースが照合して、後から来た側を duplicate key で拒否します。未コミットの相手がいれば、その結果を待ってから判定します。
ファイルの O_CREAT と O_EXCL
access(2) で確かめてから open(2) で作ると、2回の呼び出しの間に別のプロセスが同じ名前のファイルを作れます。open(2) に O_CREAT と O_EXCL を一緒に渡すと、既にあれば EEXIST で失敗し、作ったのはこの呼び出しだと保証されます。
行とファイル、どちらでも呼び出し側の確かめを速く丁寧にしても重複が防げないのはなぜでしょうか。
確かめる操作と書き込む操作が、何回の呼び出しに分かれているかを数えてください。
この章のまとめ
- 「確かめてから INSERT」を2本同時に走らせると、制約なしでは2行入った。どちらも正しくコミットした結果で、原子性の失敗ではない。
- UNIQUE ありでも確かめの結果は同じ「無い」。後から来た INSERT が約1秒(A のコミットまで)待ってから duplicate key で拒否され、1行になった。
- UNIQUE は確かめと書き込みを1つの操作にしてデータベースに引き受けさせる。既定では NULL は重複してよい。
- ACID の C は、PG の用語集では整合性制約への準拠、MySQL の文書では主にクラッシュからの保護。どちらの意味でも、一意性はデータベースが自分で思いつく性質ではない。
- Codd の
All rows are distinctと PG の「同一行を持てる」は対象が違う。SQL の表では一意性は宣言して初めて付く。 - ファイルでも、access(2) の後の open(2) には隙間が残り、O_CREAT と O_EXCL がそれを閉じる。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
ACID の C(一貫性)について、PostgreSQL 18 と MySQL 8.4 の公式文書の書き方として正しいのはどれですか。
用語集(3語)
- UNIQUE 制約
- 列(または列の組)の値が重複しないことを宣言する制約。PostgreSQL では一意な B-tree 索引が自動で作られる。既定では NULL どうしは重複と見なさない。
- 一貫性(ACID の C)
- PostgreSQL の用語集は整合性制約に常に従う性質、MySQL の文書は主にクラッシュからデータを守る内部処理として書く。文書ごとに指すものが違う。
- O_EXCL
- open(2) のフラグ。O_CREAT と一緒に渡すと、ファイルが既にあれば EEXIST で失敗し、この呼び出しが作ったことを保証する。
出典(一次ソース)
- PostgreSQL 18 Documentation — 5.1. Table Basics ↗
- PostgreSQL 18 Documentation — 5.5. Constraints(5.5.3 Unique Constraints) ↗
- PostgreSQL 18 Documentation — 63.5. Index Uniqueness Checks ↗
- PostgreSQL 18 Documentation — Glossary(Consistency) ↗
- PostgreSQL 15 Release Notes(Release date: 2022-10-13) ↗
- MySQL 8.4 Reference Manual — 17.2 InnoDB and the ACID Model ↗
- MySQL 8.4 Reference Manual — 15.1.15 CREATE INDEX Statement ↗
- E. F. Codd, A Relational Model of Data for Large Shared Data Banks, CACM 13(6), 1970 ↗
- Jim Gray, The Transaction Concept: Virtues and Limitations, Tandem TR 81.3, 1981 ↗
- Linux man-pages 6.19 — open(2) ↗
- Linux man-pages 6.19 — access(2) ↗