はじめに
前回の「ダーティリード」に続き、トランザクション分離レベルで起きる異常現象の2つ目、「ノンリピータブルリード(Non-Repeatable Read)」について解説します。
PostgreSQLではダーティリードは絶対に発生しませんでしたが、今回のノンリピータブルリードは「Postgresのデフォルト設定(READ COMMITTED)」のままだと発生する現象です。実務で思わぬバグを生む原因になりやすいため、Dockerを使ったハンズオンを通して実際に体験してみましょう!
1. ノンリピータブルリードとは何か?
一言でいうと、「同じトランザクションの中で、同じデータを2回読んだだけなのに、1回目と2回目でデータの中身が変わってしまう現象」です。
自分が1回目にデータを読んでから2回目を読むまでのわずかな隙に、別の人がそのデータを更新(UPDATE)してコミット(確定)してしまったために起こります。「さっき読んだデータをもう一回読もうとしたら、同じ値が再現できない」ことから、ノンリピータブル(再現不可)と呼ばれます。
2. シーケンス図で見る:ノンリピータブルリードの恐怖
口座の残高を例に、Aのシステム(残高を2回確認する)と、Bのシステム(途中で引き出し処理をする)が同時に動いた場合のタイムラインです。
Aのシステムが「10万円ある」という前提で複雑な計算をしている途中で、いきなりデータベースの値が「7万円」にすり替わってしまうため、計算が合わなくなるなどのバグに繋がります。
3. ハンズオン準備(Docker Compose)
実際にPostgreSQLを立ち上げて実験してみましょう。適当な作業ディレクトリを作成し、以下の docker-compose.yml を配置します。
services:
db:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: testdb
ports:
- "5435:5432"
コンテナを起動します。
docker compose up -d
初期データの投入
ターミナルを1つ開き、データベースに接続してテスト用のテーブルとデータを作成します。
# PostgreSQLに接続
docker compose exec db psql -U user -d testdb
-- テーブル作成とデータ投入
CREATE TABLE accounts (
id INT PRIMARY KEY,
name VARCHAR(50),
balance INT
);
INSERT INTO accounts VALUES (1, 'あなたの口座', 100000);
4. 実験①:デフォルト(READ COMMITTED)で発生させる
ターミナルを2つ並べて開きます(ここではTerminal A、Terminal Bと呼びます)。
両方とも docker-compose exec db psql -U user -d testdb で接続した状態からスタートします。
Step 1: Terminal Aで1回目の確認
-- Terminal A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- 結果: 100000
Step 2: Terminal Bで残高を更新して確定(コミット)
Terminal Aがまだトランザクション中(BEGINのまま)の状態で、Terminal Bから更新をかけます。
-- Terminal B
BEGIN;
UPDATE accounts SET balance = 70000 WHERE id = 1;
COMMIT;
-- これでデータベース上の残高は70000円に確定しました
Step 3: Terminal Aで2回目の確認
-- Terminal A
SELECT balance FROM accounts WHERE id = 1;
-- 結果: 70000 (★変わってしまった!)
COMMIT;
これがノンリピータブルリードです。
Aとしては何もしていないのに、自分のトランザクションの途中で他人のコミットが割り込んできたため、見えている世界が変わってしまいました。
5. 実験②:REPEATABLE READで防いでみよう
Postgresの設定を1つ厳しくし、「自分が処理を始めた時点のデータを、処理が終わるまでずっと見せ続ける(写真のようにスナップショットを撮っておく)」ように設定してみましょう。これが REPEATABLE READ レベルです。
データを10万円に戻してから再開します。
-- 事前準備(どちらかのTerminalで実行)
UPDATE accounts SET balance = 100000 WHERE id = 1;
Step 1: Terminal Aでレベルを上げて1回目の確認
-- Terminal A
BEGIN;
-- ★トランザクション分離レベルを厳しくする!
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- 結果: 100000
Step 2: Terminal Bで残高を更新して確定
-- Terminal B
BEGIN;
UPDATE accounts SET balance = 70000 WHERE id = 1;
COMMIT;
Step 3: Terminal Aで2回目の確認
-- Terminal A
SELECT balance FROM accounts WHERE id = 1;
-- 結果: 100000 (★変わらない!!)
COMMIT;
いかがでしょうか!
Terminal Bが裏でデータを7万円に書き換えたにもかかわらず、Terminal Aが2回目に読み取った値は「10万円」のまま維持されました。
。
6. まとめ
- ノンリピータブルリードとは、他人の更新(UPDATE)&コミットによって、同じトランザクション内でも2回目に読んだデータが変わってしまう現象。
- PostgreSQLのデフォルト設定では、この現象は普通に発生する。
- 防ぐためには、トランザクション分離レベルを REPEATABLE READ に引き上げる必要がある。
トランザクション設計をする際は、「この処理の最中に、他人にデータを書き換えられたら困るか?」を考え、必要に応じて分離レベルを使い分けることがデータベース設計の肝となります。







