- 設計
トランザクション設計の考え方|境界の決め方と長い処理の分割手順
トランザクション設計とは、「どこからどこまでを1つのトランザクションにするか(境界)」を処理ごとに決め、分離レベル・ロックの順序・うまくいかなかったときの扱いまでを書き残す作業です。 境界の基準は1つで、「同時に成り立っていなければならない条件(不変条件)を守れる最小の範囲」です。
この記事では、境界を決める手順、処理ごとに書くトランザクション設計表、10万件の更新を一括と1000件ごとのコミットで比べた実行結果、途中で止まっても続きから再開できるSQL、そして「1つにまとめる/分割して補償処理/アウトボックスで後追い」の選び方を扱います。BEGIN や COMMIT の構文、フレームワークの設定方法は扱いません。
確認環境:Ubuntu 24.04、PostgreSQL 16.15(既定の分離レベル read committed)。SQLと出力はすべてこの環境で実行したものです(確認日 2026-10-05)。題材は架空の備品予約と商品の価格改定です。
この記事の結論
- 1トランザクションの範囲=同時に成り立たなければならない不変条件(例:在庫の減算と予約の登録)。
- 外部APIの呼び出し・メール送信・ファイル出力は境界の外に出す。 DBのロールバックでは取り消せず、待ち時間のあいだロックを持ち続けるため。
- 大量更新は1000件などの単位でコミットし、処理済みキーを同じトランザクションで進める。 途中で止まっても続きから再開でき、他の処理を待たせる時間が短くなる。
- 複数の表を更新するときは、ロックを取る順序を設計表に書いてそろえる。
トランザクション設計の早見表
| 決めること | 決め方 | 書く場所 |
|---|---|---|
| 境界(範囲) | 不変条件を1つずつ書き出し、その条件に関わる更新だけを入れる | 設計表の「更新テーブル」 |
| 境界の外に出すもの | 外部API、メール、ファイル出力、利用者の入力待ち | 設計表の「境界の外」 |
| 分離レベル | 既定(PostgreSQLは read committed)から始め、理由があるときだけ上げる | 設計表の「分離レベル」 |
| ロックの順序 | 表の順、同じ表の中は主キーの昇順 | 設計表の「ロック順」 |
| うまくいかなかったとき | ロールバックして利用者に返す/再実行する/補償処理を行う | 設計表の「エラー時の扱い」 |
| 長い処理 | 件数で区切ってコミットし、処理済みキーで再開する | 分割の手順 |
| 外部への通知 | アウトボックス表に書いて、別の処理が送る | アウトボックス |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「トランザクション設計の考え方|境界の決め方と長い処理の分割手順」
境界を決める手順
| 手順 | やること | 備品予約の例 |
|---|---|---|
| 1 | 処理の中の更新と外部への働きかけを全部書き出す | 在庫を減らす、予約を登録する、確認メールを送る、外部カレンダーに登録する |
| 2 | 不変条件を文章で書く | 「貸出可能数は0以上」「予約が1件あれば在庫が1つ減っている」 |
| 3 | 不変条件ごとに、関わる更新を丸で囲む | 在庫の減算と予約の登録 |
| 4 | 丸の外にあるものを境界の外へ出す | 確認メール、外部カレンダー |
| 5 | 境界の外のものがうまくいかなかったときの扱いを決める | メールは再送する、カレンダー登録は補償処理で予約を取り消す |
| 6 | ロックを取る順序を決める | 在庫 → 予約 の順 |
| 7 | 設計表に書く | 次の章 |
「一緒にロールバックされないと困るもの」だけが境界の中に入ります。 「同じ画面の操作だから」「同じメソッドの中だから」は境界の理由になりません。
境界の中に入れるもの・外に出すもの
| 対象 | 中/外 | 理由 |
|---|---|---|
| 同じ不変条件に関わる複数の表の更新 | 中 | 片方だけ反映されると条件が崩れる |
| 送信依頼や履歴の行の追加(同じDB内) | 中 | 業務データと一緒に確定させたい |
| 外部APIの呼び出し | 外 | DBのロールバックで取り消せない。応答待ちのあいだロックを持つ |
| メール送信 | 外 | 送ったあとにロールバックしても取り消せない |
| ファイル出力 | 外 | 途中まで書いたファイルはDBと一緒に戻らない |
| 利用者の入力待ち・確認画面 | 外 | 待ち時間が読めない |
| 重い集計の読み取り | 外、または読み取り専用の別トランザクション | 更新のロックを長く持たない |
トランザクション設計表(テンプレートと記入例)
処理ごとに1行で書きます。
テンプレート
| No | 処理名 | 守る不変条件 | 更新テーブル(ロック順) | 分離レベル | 境界の外 | エラー時の扱い | 再実行 |
|---|---|---|---|---|---|---|---|
No 処理名 守る不変条件 更新テーブル(ロック順) 分離レベル 境界の外 エラー時の扱い 再実行
記入例(備品予約システム)
| No | 処理名 | 守る不変条件 | 更新テーブル(ロック順) | 分離レベル | 境界の外 | エラー時の扱い | 再実行 |
|---|---|---|---|---|---|---|---|
| 1 | 予約登録 | 貸出可能数は0以上。予約1件につき在庫が1つ減っている | 1. item_stock 2. reservations 3. outbox | read committed | 確認メール(outbox経由) | 全体をロールバックし「在庫がありません」と返す | 利用者が操作し直す |
| 2 | 予約取消(補償を含む) | 取り消した予約の在庫は1回だけ戻る | 1. reservations 2. item_stock | read committed | なし | ロールバック。状態が pending でなければ何もしない | 何度実行しても同じ結果 |
| 3 | 外部カレンダー登録 | 予約が confirmed なら外部に登録済み | reservations(状態の更新のみ) | read committed | 外部APIの呼び出し | 3回まで再試行し、通らなければ No.2 の補償処理を呼ぶ | 外部側の重複は予約IDで防ぐ |
| 4 | 確認メール送信 | 送信済みの行には sent_at が入っている | outbox | read committed | メール送信 | sent_at を空のまま残し、次の回で再送 | 同じメールが2回届くことがある |
| 5 | 価格改定(10万件) | 各商品は1回だけ改定される | 1. job_progress 2. products(主キー昇順) | read committed | なし | その回の1000件だけロールバック。原因を直して同じ呼び出しを再実行 | 処理済みキーの続きから |
| 6 | 月次の集計 | 集計中は1つの時点のデータで読む | なし(読み取りのみ) | repeatable read | 帳票ファイルの出力 | 最初からやり直す | 可 |
No 処理名 守る不変条件 更新テーブル(ロック順) 分離レベル 境界の外 エラー時の扱い 再実行
1 予約登録 貸出可能数は0以上。予約1件につき在庫が1つ減っている 1. item_stock 2. reservations 3. outbox read committed 確認メール(outbox経由) 全体をロールバックし「在庫がありません」と返す 利用者が操作し直す
2 予約取消(補償を含む) 取り消した予約の在庫は1回だけ戻る 1. reservations 2. item_stock read committed なし ロールバック。状態が pending でなければ何もしない 何度実行しても同じ結果
3 外部カレンダー登録 予約が confirmed なら外部に登録済み reservations(状態の更新のみ) read committed 外部APIの呼び出し 3回まで再試行し、通らなければ No.2 の補償処理を呼ぶ 外部側の重複は予約IDで防ぐ
4 確認メール送信 送信済みの行には sent_at が入っている outbox read committed メール送信 sent_at を空のまま残し、次の回で再送 同じメールが2回届くことがある
5 価格改定(10万件) 各商品は1回だけ改定される 1. job_progress 2. products(主キー昇順) read committed なし その回の1000件だけロールバック。原因を直して同じ呼び出しを再実行 処理済みキーの続きから
6 月次の集計 集計中は1つの時点のデータで読む なし(読み取りのみ) repeatable read 帳票ファイルの出力 最初からやり直す 可
各列の書き方
| 列 | 書くこと | 目安 |
|---|---|---|
| 守る不変条件 | 「〜は常に〜である」の形の文 | 1〜2個。3個以上なら処理を分けられないか見直す |
| 更新テーブル(ロック順) | 更新する表を、ロックを取る順に番号付きで | 全処理で同じ順にそろえる |
| 分離レベル | 既定のままか、上げるなら理由 | 上げるのは「1つの時点で読みたい」など理由が書けるときだけ |
| 境界の外 | 外部API、メール、ファイルなど | 空欄なら「なし」と書く |
| エラー時の扱い | ロールバック後に誰が何をするか | 「ロールバックする」だけで終わらせない |
| 再実行 | もう一度実行してよいか、どこから再開するか | 「何度実行しても同じ結果」になる形を目指す |
1つにまとめる・分割して補償処理・アウトボックスの選び方
| 条件 | 選ぶもの | 内容 |
|---|---|---|
| 更新がすべて同じDBの中で、すぐ終わる | 1つにまとめる | 1トランザクションで確定。うまくいかなければ全体をロールバック |
| 同じDBの中だが、件数が多く時間がかかる | 件数で区切ってコミット+処理済みキー | 1回分ごとに確定し、止まったら続きから |
| 外部への通知で、少し遅れて届いてよい(メール、他システムへの連絡) | アウトボックスで後追い | 送信依頼の行を業務データと同じトランザクションで書き、別の処理が送る |
| 外部の結果によって、こちらの確定・取消が決まる | 分割して補償処理 | 先に「仮」の状態で確定し、外部の結果を見て確定か取消(補償)を行う |
| 利用者の確認や承認をはさむ | 分割して状態で管理 | 申請中・承認済みなどの状態を列に持ち、待ち時間はトランザクションの外 |
似た言葉との違いも整理しておきます。
| 言葉 | 何を取り消すか | 取り消し方 | 途中の状態が見えるか |
|---|---|---|---|
| ロールバック | 未確定の変更 | DBが自動で元に戻す | 見えない |
| 補償処理 | 確定済みの変更 | 打ち消す更新を自分で書いて実行する | 見える(仮の状態を列で表す) |
| 再実行(リトライ) | 取り消さない | 同じ処理をもう一度行う | 処理による |
1つにまとめる:予約登録の例
準備として3つの表を作ります。
CREATE TABLE item_stock (
item_id integer PRIMARY KEY,
available integer NOT NULL CHECK (available >= 0)
);
CREATE TABLE reservations (
reservation_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
item_id integer NOT NULL REFERENCES item_stock (item_id),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'cancelled'))
);
CREATE TABLE outbox (
outbox_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz
);
INSERT INTO item_stock VALUES (1, 2), (2, 0);
在庫の減算、予約の登録、メールの送信依頼(outbox への行の追加)を1トランザクションにします。
BEGIN;
UPDATE item_stock SET available = available - 1 WHERE item_id = 1;
WITH r AS (
INSERT INTO reservations (item_id) VALUES (1) RETURNING reservation_id, item_id
)
INSERT INTO outbox (event_type, payload)
SELECT 'reservation_created', jsonb_build_object('reservation_id', reservation_id, 'item_id', item_id) FROM r;
COMMIT;
SELECT * FROM item_stock ORDER BY item_id;
SELECT reservation_id, item_id, status FROM reservations;
SELECT outbox_id, event_type, payload, sent_at FROM outbox;
item_id | available
---------+-----------
1 | 1
2 | 0
reservation_id | item_id | status
----------------+---------+---------
1 | 1 | pending
outbox_id | event_type | payload | sent_at
-----------+---------------------+-------------------------------------+---------
1 | reservation_created | {"item_id": 1, "reservation_id": 1} |
在庫が0の備品を予約しようとすると、CHECK 制約で止まり、予約の登録も一緒に取り消されます。
BEGIN;
UPDATE item_stock SET available = available - 1 WHERE item_id = 2;
INSERT INTO reservations (item_id) VALUES (2);
COMMIT;
SELECT count(*) AS reservations_for_item2 FROM reservations WHERE item_id = 2;
BEGIN
psql:run.sql:16: ERROR: new row for relation "item_stock" violates check constraint "item_stock_available_check"
DETAIL: Failing row contains (2, -1).
psql:run.sql:17: ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
reservations_for_item2
------------------------
0
COMMIT と書いても、途中でエラーになったトランザクションは ROLLBACK になります(出力の ROLLBACK の行)。不変条件を制約として表に持たせておくと、アプリケーションの判定をすり抜けた更新もDBが止めます。
アウトボックスで後追い:メール送信を境界の外に出す
メール送信そのものは、別の処理(ワーカー)が outbox の未送信の行を読んで行います。複数のワーカーが同じ行を取らないよう、FOR UPDATE SKIP LOCKED で取り出します。
BEGIN;
-- 未送信の行を2件ロックして取り出す。他のワーカーがロック中の行は飛ばす
SELECT array_agg(outbox_id) AS ids FROM (
SELECT outbox_id FROM outbox
WHERE sent_at IS NULL
ORDER BY outbox_id
LIMIT 2
FOR UPDATE SKIP LOCKED
) t \gset
\echo 取り出した行: :ids
SELECT pg_sleep(1); -- ここでメール送信などを行う(検証用に1秒待つ)
UPDATE outbox SET sent_at = now() WHERE outbox_id = ANY (:'ids'::bigint[]);
COMMIT;
未送信の行を4件にして、ワーカーを2つ同時に動かした結果です(psql で実行。\gset は結果を変数に入れる psql の機能です)。
ワーカー1
取り出した行: {1,2}
ワーカー2
取り出した行: {3,4}
SELECT outbox_id, sent_at IS NOT NULL AS sent FROM outbox ORDER BY 1;
outbox_id | sent
-----------+------
1 | t
2 | t
3 | t
4 | t
ワーカー2は、ワーカー1がロックしている1・2を待たずに飛ばして3・4を取りました。
この形では、メールを送ったあと sent_at の更新前にワーカーが止まると、次の回で同じメールがもう一度送られます。アウトボックスは「少なくとも1回届く」方式なので、受け取る側が重複しても困らないようにします(メールなら件名に予約番号を入れる、他システムへの連絡なら outbox_id を渡して重複を見分けてもらう)。
分割して補償処理:外部の結果で確定か取消かを決める
外部カレンダーへの登録のように、外部の結果でこちらの状態が決まる処理は、次の3つに分けます。
| 段階 | トランザクション | 内容 |
|---|---|---|
| 1. 仮の確定 | T1 | 在庫を減らし、予約を pending で登録する |
| 2. 外部の呼び出し | なし(境界の外) | 外部APIを呼ぶ。予約IDを渡して重複登録を防いでもらう |
| 3a. 確定 | T2 | うまくいったら予約を confirmed にする |
| 3b. 補償 | T2' | うまくいかなかったら予約を cancelled にし、在庫を戻す |
補償処理は、何度実行しても同じ結果になる形で書きます。次のSQLは「pending の予約を取り消せたときだけ在庫を戻す」ので、2回実行しても在庫は1回しか戻りません。
WITH c AS (
UPDATE reservations SET status = 'cancelled'
WHERE reservation_id = 1 AND status = 'pending'
RETURNING item_id
)
UPDATE item_stock s SET available = s.available + 1 FROM c WHERE s.item_id = c.item_id;
1回目と2回目の実行結果、そのあとの在庫です。
UPDATE 1
UPDATE 0
item_id | available
---------+-----------
1 | 2
2 | 0
1回目は在庫が1から2に戻り、2回目は UPDATE 0(何も変わらない)でした。補償処理は再試行されることが多いので、この性質が大事です。
長い処理の分割:10万件の更新を一括と1000件ごとで比べる
題材は商品10万件の価格改定(価格を1.1倍)です。同じプロシージャを、コミットの単位だけ変えて比べます。
-- 題材:商品10万件の価格改定(価格を1.1倍にする)
CREATE TABLE products (
product_id integer PRIMARY KEY,
price integer NOT NULL CHECK (price BETWEEN 1 AND 1000000),
revised_at timestamptz
);
INSERT INTO products (product_id, price)
SELECT g, 1000 + (g % 500) * 10 FROM generate_series(1, 100000) g;
-- 処理済みキーを覚えておく表(ジョブごとに1行)
CREATE TABLE job_progress (
job_name text PRIMARY KEY,
last_key integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO job_progress (job_name) VALUES ('price_revision');
-- p_batch 件ずつ処理する。p_commit_each が true なら1回分ごとにコミットする
CREATE PROCEDURE revise_prices(p_batch integer, p_commit_each boolean)
LANGUAGE plpgsql AS $$
DECLARE
v_last integer;
v_max integer;
BEGIN
LOOP
-- 1. どこまで終わったかを読む(同じジョブの二重起動はここで待たされる)
SELECT last_key INTO v_last FROM job_progress
WHERE job_name = 'price_revision' FOR UPDATE;
-- 2. 続きの p_batch 件を主キーの昇順でロックして更新する
WITH target AS (
SELECT product_id FROM products
WHERE product_id > v_last
ORDER BY product_id
LIMIT p_batch
FOR UPDATE
), done AS (
UPDATE products p
SET price = round(p.price * 1.1), revised_at = now()
FROM target t
WHERE p.product_id = t.product_id
RETURNING p.product_id
)
SELECT max(product_id) INTO v_max FROM done;
EXIT WHEN v_max IS NULL; -- 続きが無ければ終わり
-- 3. 処理済みキーを同じトランザクションの中で進める
UPDATE job_progress SET last_key = v_max, updated_at = now()
WHERE job_name = 'price_revision';
PERFORM pg_sleep(0.05); -- 1回分の処理にかかる時間の代わり(検証用)
IF p_commit_each THEN
COMMIT;
END IF;
END LOOP;
END;
$$;
pg_sleep(0.05) は、1回分(1000件)の処理に時間がかかる状況を作るための検証用の待ちです。100回で合計5秒ほどになります。
他の処理を待たせる時間
価格改定を始めて1秒後に、別の接続から商品1件を更新し、返ってくるまでの時間を測りました。
-- 接続1:価格改定
\timing on
CALL revise_prices(1000, false); -- 一括(1トランザクション)
-- または
CALL revise_prices(1000, true); -- 1000件ごとにコミット
-- 接続2:開始から1秒後に実行
\timing on
UPDATE products SET price = price + 1 WHERE product_id = 500;
| 方式 | 接続1(価格改定)の時間 | 接続2(1件の更新)の時間 |
|---|---|---|
| 一括(1トランザクション) | 8239.813 ms | 7223.708 ms |
| 1000件ごとにコミット | 9041.734 ms | 2.863 ms |
一括では、接続2は価格改定が終わるまで約7.2秒待たされました。待っているあいだの様子は pg_stat_activity で確認できます。
SELECT a.pid, a.wait_event_type, a.wait_event, pg_blocking_pids(a.pid) AS blocked_by, left(a.query, 45)
FROM pg_stat_activity a
WHERE datname = 'appdb' AND pid <> pg_backend_pid() AND state = 'active' ORDER BY 1;
4595|Timeout|PgSleep|{}|CALL revise_prices(1000, false)
4603|Lock|transactionid|{4595}|UPDATE products SET price = price + 1 WHERE p
(psql の -At オプションで出力。データベース名は appdb に置き換えています。)2行目が接続2で、wait_event_type が Lock、blocked_by に価格改定の接続(4595)が出ています。1000件ごとにコミットする方式では、接続2が触る行のロックはすでに解放されていたため、待ちは発生しませんでした。全体の時間は、コミットの回数が増えるぶん少し長くなっています(8.2秒と9.0秒)。
途中でエラーになる行があるとき
45500番の商品だけ、1.1倍すると価格の上限(100万)を超えるようにして、両方の方式を実行します。
UPDATE products SET price = 950000 WHERE product_id = 45500;
CALL revise_prices(1000, false);
SELECT count(*) FILTER (WHERE revised_at IS NOT NULL) AS revised,
(SELECT last_key FROM job_progress) AS last_key FROM products;
psql:run.sql:3: ERROR: new row for relation "products" violates check constraint "products_price_check"
DETAIL: Failing row contains (45500, 1045000, 2026-10-05 14:14:36.166624+00).
…(省略)…
PL/pgSQL function revise_prices(integer,boolean) line 12 at SQL statement
revised | last_key
---------+----------
0 | 0
一括では、45,000件まで進んでいた更新もすべて取り消され、改定済みは0件に戻りました。
CALL revise_prices(1000, true);
SELECT count(*) FILTER (WHERE revised_at IS NOT NULL) AS revised,
(SELECT last_key FROM job_progress) AS last_key FROM products;
psql:run.sql:7: ERROR: new row for relation "products" violates check constraint "products_price_check"
DETAIL: Failing row contains (45500, 1045000, 2026-10-05 14:14:44.905115+00).
…(省略)…
revised | last_key
---------+----------
45000 | 45000
1000件ごとのコミットでは、45,000件までが確定して残り、処理済みキーも45000で止まっています。取り消されたのは、エラーになった行を含む1回分(45,001〜46,000)だけです。
原因を直して再実行する
UPDATE products SET price = 900000 WHERE product_id = 45500;
CALL revise_prices(1000, true);
SELECT count(*) FILTER (WHERE revised_at IS NOT NULL) AS revised,
(SELECT last_key FROM job_progress) AS last_key FROM products;
-- 二重に改定された行が無いことを確かめる(元の価格×1.1と比べる)
SELECT count(*) AS mismatch FROM products
WHERE product_id <> 45500 AND price <> round((1000 + (product_id % 500) * 10) * 1.1);
CALL
revised | last_key
---------+----------
100000 | 100000
mismatch
----------
0
同じ呼び出しをそのまま実行するだけで45,001件目から再開し、二重に1.1倍された行は0件でした。完了後にもう一度呼んでも、続きが無いので何も変わりません(mismatch は0のまま)。
処理済みキー方式のポイント
| ポイント | 内容 |
|---|---|
| 処理済みキーは業務データと同じトランザクションで進める | 「データは更新したがキーは進んでいない」という状態が生まれない |
| 主キーの昇順で区切る | 「どこまで終わったか」が1つの値で表せる。ロックの順序もそろう |
進み具合の行を FOR UPDATE で読む |
同じジョブを二重に起動しても、片方は待たされる |
| 1回分の件数 | 他の処理を待たせてよい時間から決める。今回は1000件で約0.05秒+更新の時間 |
| 分割してよい条件 | 途中まで反映された状態を他の処理が見ても困らないこと |
最後の条件が大切です。「全商品が同時に新価格に切り替わらなければならない」なら分割できません。 その場合は、新価格を別の列に書き込む作業を分割して行い、最後に「どちらの列を使うか」を1行の設定で切り替える、という形にすると、切り替えそのものは一瞬のトランザクションで済みます。
ロックの順序をそろえる
2つの処理が同じ2行を逆の順に更新すると、デッドロックになります。
-- 接続1 -- 接続2
BEGIN; BEGIN;
UPDATE products SET price = price + 1 UPDATE products SET price = price + 1
WHERE product_id = 1; WHERE product_id = 2;
SELECT pg_sleep(1); SELECT pg_sleep(1);
UPDATE products SET price = price + 1 UPDATE products SET price = price + 1
WHERE product_id = 2; WHERE product_id = 1;
COMMIT; COMMIT;
ERROR: deadlock detected
DETAIL: Process 4917 waits for ShareLock on transaction 169380; blocked by process 4920.
Process 4920 waits for ShareLock on transaction 169379; blocked by process 4917.
HINT: See server log for query details.
CONTEXT: while updating tuple (3089,110) in relation "products"
片方がエラーで取り消され、もう片方は完了しました。両方の処理で、先に主キーの昇順でロックを取るようにすると、後から来た方が待つだけになり、両方とも完了します。
BEGIN;
SELECT product_id FROM products WHERE product_id IN (1, 2) ORDER BY product_id FOR UPDATE;
SELECT pg_sleep(1);
UPDATE products SET price = price + 1 WHERE product_id IN (1, 2);
COMMIT;
-- 2つの接続の両方で
UPDATE 2
COMMIT
設計表の「更新テーブル(ロック順)」の列は、この順序を処理をまたいでそろえるためにあります。
分離レベルは既定から始める
分離レベルを上げるのは、理由を設計表に書けるときだけにします。設計表の No.6(月次の集計)で repeatable read を選んだ理由を、実行して確かめます。集計の途中(1回目と2回目の SELECT のあいだ)に、別の接続が予約を1件追加してコミットしています。
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) AS pending FROM reservations WHERE status = 'pending'; -- 1回目
SELECT pg_sleep(1); -- このあいだに別の接続が予約を1件追加
SELECT count(*) AS pending FROM reservations WHERE status = 'pending'; -- 2回目
COMMIT;
SELECT count(*) AS pending FROM reservations WHERE status = 'pending'; -- コミット後
pending
---------
0
pending
---------
0
pending
---------
1
トランザクションの中では2回とも0件で、コミット後に1件になりました。複数のSELECTで1つの帳票を作るときに、数字のつじつまが合います。
確かめて分かったこと
- 一括更新は、処理時間の大半のあいだ他の更新を待たせました。 約8.2秒の価格改定に対し、開始1秒後に来た1件の更新は約7.2秒待ちました。1000件ごとのコミットでは2.9ミリ秒でした。
- 分割すると全体の時間は少し延びました(8.2秒から9.0秒)。コミットを100回行うためです。待たせる時間の短さと引き換えになります。
- 一括でエラーになると、進んでいた45,000件もすべて取り消されました。 分割では取り消されたのは1回分の1000件だけで、原因を直したあと同じ呼び出しで続きから再開できました。
- プロシージャの中の
COMMITは、エラーより前の分を確定させます。CALLがエラーで終わっても、それまでにコミットした45回分は残っていました。 COMMITと書いても、エラーになったトランザクションはROLLBACKと表示されます。 アプリケーション側でも、コミットの結果を確かめてから次へ進む必要があります。- 同じ価格改定を2つ同時に起動しても、二重に改定された行は0件でした。 進み具合の行を
FOR UPDATEで読んでいるため、2つの呼び出しが交互に1回分ずつ進みます。 SKIP LOCKEDを付けると、2つのワーカーが待ち合わずに別々の行を取りました(1・2と3・4)。
よくある質問
Q. トランザクション設計とは何ですか? A. 処理ごとにトランザクションの境界・分離レベル・ロックの順序・エラー時の扱いを決めて書き残すことです。構文の話ではなく、「どこまでを一緒に確定させるか」を決める設計作業です。
Q. トランザクションの境界(範囲)はどう決めますか? A. 同時に成り立たなければならない不変条件に関わる更新だけを1つの範囲にします。外部APIの呼び出し、メール送信、ファイル出力、利用者の入力待ちは範囲の外に出します。
Q. 長いトランザクションを分割すると整合性はどうなりますか? A. 途中まで反映された状態が他の処理から見えるようになります。その状態を見られても困らない処理だけを分割し、処理済みキーを同じトランザクションで進めて、二重の更新と抜けを防ぎます。
Q. 補償処理とロールバックの違いは何ですか? A. ロールバックは未確定の変更をDBが自動で元に戻すこと、補償処理は確定済みの変更を打ち消す更新を自分で実行することです。補償処理は、何度実行しても同じ結果になる形で書きます。
Q. アウトボックスとは何ですか? A. 外部へ送りたい内容を、業務データと同じトランザクションで表に書いておき、別の処理があとから送る方式です。業務データだけ確定して通知が出ない、という食い違いを防げます。
まとめ
- 境界は不変条件で決める:一緒にロールバックされないと困る更新だけを1トランザクションに入れる。
- 外部API・メール・ファイル出力は境界の外:送信依頼をアウトボックスに書き、別の処理が送る。
- 外部の結果で確定が決まる処理は、仮の状態と補償処理に分ける:補償は何度実行しても同じ結果になる形で書く。
- 大量更新は件数で区切ってコミット:処理済みキーを同じトランザクションで進めると、続きから再開できる。
- ロックの順序を設計表に書いてそろえる:表の順、同じ表では主キーの昇順。
- 分離レベルは既定から始める:上げるのは理由を書けるときだけ。
- 設計表は処理ごとに1行:不変条件・更新テーブル・分離レベル・境界の外・エラー時の扱い・再実行を書く。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「トランザクション設計の考え方|境界の決め方と長い処理の分割手順」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。