TL;DR
- データ不整合の大半は「同時更新(race condition)」「eventual consistency の前提誤り」「二重書き込み」「分散トランザクションの分割失敗」の4つに集中します。
- 単一DB内ならトランザクション境界と楽観/悲観ロックでほぼ防げます。壊れるのは「DBの外」に書き込みが漏れたときです。
- 複数サービス・複数ストアにまたがる更新は、冪等性を前提に outbox パターンで書き込みを1本化し、Saga パターンで補償する設計が現実解です。
- 「スケールすると壊れる」ポイントは、台数が増えて同時実行が常態化し、
SELECTしてからUPDATEするまでの隙間が広がる箇所です。
はじめに
この記事は、Webサービスのバックエンド設計者・SRE を対象に、データ不整合がなぜ起きるのかと、どの対策をどの境界で使うのかを整理する解説記事です。読み終えたとき、自分のシステムで「どこが壊れる候補か」を特定し、適切な対策(ロック / 冪等性 / Saga / outbox)を選べる状態を目指します。
データ不整合は、開発初期にはまず表面化しません。アクセスが少なくリクエストが直列に近いため、設計上の穴があっても偶然うまく動いてしまうからです。問題が顕在化するのはスケールしてからです。本記事は一貫して「スケールすると壊れるポイント」に寄せて説明します。
なぜデータ不整合が起きるのか
1. race condition(同時更新の競合)
最も古典的かつ最頻出の原因です。複数のリクエストが「現在値を読む → 計算する → 書き戻す」という手順を同時に実行すると、片方の更新が消えます(lost update)。
典型例が在庫やポイント残高の減算です。残高100に対して2リクエストが同時に「100を読む → 10引く → 90を書く」を実行すると、本来80になるべき残高が90のまま残ります。PostgreSQL 公式ドキュメントでも、この種の競合は分離レベル(isolation level)が READ COMMITTED のままだとアプリ側で明示的にロックしない限り防げないと明記されています(https://www.postgresql.org/docs/current/transaction-iso.html )。
スケールとの関係は明快です。サーバ台数とリクエスト並列度が上がるほど、SELECT と UPDATE の間の「隙間」に別トランザクションが割り込む確率が上がります[経験則]。台数1〜2台で問題なくても、10台に増やした瞬間に再現するのはこのためです。
2. eventual consistency の前提誤り
レプリカ構成やキャッシュを入れると、書き込み直後の読み取りが古い値を返すことがあります。これはバグではなく、結果整合性(eventual consistency)という設計上の性質です。問題は、アプリが「書いたら即座に読める」という強整合性を暗黙に前提にしているときに起きます。
分散システムでは整合性・可用性・分断耐性を同時に最大化できないという制約(CAP 定理)が知られており、AWS の分散システム設計に関する公式記事でも、結果整合なストアでは「自分が書いた値をすぐ読めるとは限らない(read-your-writes が保証されない)」前提で設計すべきだと整理されています(https://aws.amazon.com/builders-library/challenges-with-distributed-systems/ )。
スケールとの関係では、リードレプリカを増やして読み取りを分散させた瞬間にこの問題が顕在化します。レプリカ遅延(replication lag)が数ミリ〜数百ミリ秒生じ、「登録直後に一覧へ出ない」といった不整合に見える挙動が出ます[経験則]。
3. 二重書き込み(dual write)
1つの操作で2つのストアに別々に書く設計は、ほぼ確実に不整合の温床になります。例えば「DBに注文を保存し、その後メッセージキューにイベントを publish する」という処理で、DB保存は成功したがキュー送信前にプロセスが落ちると、注文は存在するのにイベントが流れません。逆順なら、イベントは流れたのに注文が無い状態になります。
2つの異なるシステムへの書き込みをアトミックに揃える汎用的な手段は存在しません。Martin Kleppmann の "Designing Data-Intensive Applications"(O'Reilly, 2017)でも、この dual-write 問題はストリーム処理と CDC(変更データキャプチャ)を扱う章の中心テーマとして繰り返し指摘されています(https://dataintensive.net/ )。
4. 分散トランザクションの分割失敗
マイクロサービス化すると、1つの業務処理が複数サービスのDB更新にまたがります。サービスごとにDBが分かれているため、単一の BEGIN ... COMMIT で囲えません。2相コミット(2PC)という選択肢はありますが、コーディネータ障害時にロックが残る・可用性を下げるといった理由で、Web系では避けられる傾向にあります。
ここでサービス境界の引き方が効いてきます。境界設計そのものは 責務分離設計の考え方 や マイクロサービス失敗パターン で扱った話と地続きで、整合性を保ちにくい場所に境界を引いてしまうと、後段の対策コストが跳ね上がります。
対策設計:どの境界で何を使うか
対策は「単一DB内で閉じるか」「DBの外をまたぐか」で大きく2系統に分かれます。
単一DB内:トランザクション境界とロック
同一DB内の更新なら、トランザクションとロックでほぼ解決します。race condition への対策は大きく2つです。
悲観ロック(pessimistic lock) は、読んだ行を即座にロックして他トランザクションを待たせます。SELECT ... FOR UPDATE を使います。競合が頻繁で、やり直しコストが高い処理(在庫・残高)に向きます。
楽観ロック(optimistic lock) は、ロックせずに更新時点でバージョンを照合し、ズレていたらやり直します。競合が稀な処理に向き、ロック待ちが無いぶんスループットが高くなります。
以下は race condition が起きるコードと、悲観ロック/楽観ロックでの対策を並べた例です(PostgreSQL + Python)。
# NG: race condition が起きる典型(read → 計算 → write の隙間で競合)
def withdraw_unsafe(conn, account_id, amount):
cur = conn.cursor()
cur.execute("SELECT balance FROM accounts WHERE id = %s", (account_id,))
balance = cur.fetchone()[0] # 別Txも同じ値を読める
if balance < amount:
raise ValueError("insufficient funds")
cur.execute(
"UPDATE accounts SET balance = %s WHERE id = %s",
(balance - amount, account_id), # 上書きで他Txの更新が消える
)
conn.commit()
# OK-1: 悲観ロック(FOR UPDATE で行を確保してから更新)
def withdraw_pessimistic(conn, account_id, amount):
cur = conn.cursor()
cur.execute(
"SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
(account_id,), # 他Txはここで待たされる
)
balance = cur.fetchone()[0]
if balance < amount:
raise ValueError("insufficient funds")
cur.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, account_id),
)
conn.commit()
# OK-2: 楽観ロック(version 列を WHERE で照合、0件なら衝突→リトライ)
def withdraw_optimistic(conn, account_id, amount, version):
cur = conn.cursor()
cur.execute(
"""
UPDATE accounts
SET balance = balance - %s, version = version + 1
WHERE id = %s AND version = %s AND balance >= %s
""",
(amount, account_id, version, amount),
)
if cur.rowcount == 0:
conn.rollback()
raise ConflictError("version mismatch or insufficient funds; retry")
conn.commit()
ポイントは、UPDATE accounts SET balance = balance - %s のようにDB内で計算を完結させることです。アプリ側で読んだ値を引き算して書き戻すと、必ず隙間が生まれます。PostgreSQL の行ロックや分離レベルの挙動は公式の Explicit Locking ドキュメントに整理されています(https://www.postgresql.org/docs/current/explicit-locking.html )。
なお、より厳密に防ぐなら分離レベルを SERIALIZABLE に上げる手もあります。PostgreSQL は直列化失敗を検出してエラーにするため、アプリ側でのリトライが前提になります[公式値: SERIALIZABLE では直列化異常時に serialization_failure を返す]。
DBの外をまたぐ:冪等性・outbox・Saga
問題は「DBの外」です。ここからは3つの道具を組み合わせます。
冪等性(idempotency) は、同じリクエストを複数回処理しても結果が変わらない性質です。ネットワーク越しの処理は再送が前提になるため、すべての書き込み操作に冪等キーを持たせ、重複を弾けるようにしておきます。これが二重書き込み対策の土台です。
outbox パターン は、dual write を単一トランザクションの中に畳み込む手法です。「業務テーブルへの更新」と「送信したいイベントの記録」を同じDBの同じトランザクションで書きます。イベントは別プロセス(リレー)が outbox テーブルから読んで外部へ publish します。
-- 注文の保存とイベント記録を「同一トランザクション」で行う(dual write 回避)
BEGIN;
INSERT INTO orders (id, user_id, amount, status)
VALUES ('ord_123', 42, 5000, 'confirmed');
-- 外部へ流したいイベントを同じDBに記録する(ここまでが原子的)
INSERT INTO outbox (id, aggregate_id, event_type, payload, published)
VALUES ('evt_987', 'ord_123', 'OrderConfirmed',
'{"orderId":"ord_123","amount":5000}', false);
COMMIT;
-- 別プロセスが published=false を拾って publish し、成功後に true へ更新する
こうすると、注文だけ保存されてイベントが消える・その逆という状態が原理的に起きません。両方コミットされるか、両方ロールバックされるかのどちらかになるからです。リレー側は publish 成功後に published = true を立てますが、ここは**最低1回(at-least-once)**配信になるため、受信側の冪等性が前提になります。
Saga パターン は、複数サービスにまたがる業務処理を一連のローカルトランザクションの連鎖として実装し、途中で失敗したら補償トランザクションで打ち消す方式です。「注文 → 決済 → 在庫引当」のうち在庫引当が失敗したら、決済をキャンセルし注文を取り消す、という流れを明示的に書きます。2PC のような分散ロックを避けつつ、結果整合で全体の整合性を回復できます。Saga と outbox を含むマイクロサービスのデータ整合パターンは microservices.io に体系的に整理されています(https://microservices.io/patterns/data/saga.html )。
データの所有者と更新フローをチーム横断でどう設計するかは、プロダクトチームのための DataOps の観点とも重なります。outbox のリレーやSagaの補償処理は「誰が運用責任を持つか」まで決めないと、障害時に放置されます。
スケールすると壊れるポイント(チェックリスト)
設計レビューで重点的に見るべき箇所を、壊れやすさの観点で挙げます。
| 箇所 | 壊れる理由 | 第一選択の対策 |
|---|---|---|
| 残高・在庫の減算 | 同時更新で lost update | DB内計算 + 悲観ロック or 楽観ロック |
| 「登録直後に一覧へ」 | レプリカ遅延(eventual consistency) | 直後はプライマリ読み or read-your-writes |
| DB保存 + キュー送信 | dual write でどちらか欠落 | outbox パターンで1トランザクション化 |
| 複数サービスの一括更新 | 単一Txで囲えない | Saga + 補償トランザクション |
| リトライされるAPI | 再送で二重処理 | 冪等キーで重複排除 |
判断の優先順位は、まず単一DBに閉じられないかを疑い、閉じられるならロックで済ませることです。安易にサービスやストアを分けるほど、整合性維持のコストは上がります。どこまで分けるべきかの判断軸は 技術選定の判断基準 の考え方が参考になります。
まとめと次のアクション
データ不整合は「設計の穴」がスケールによって露出する現象です。次の順で自分のシステムを点検してください。
- 残高・在庫・カウンタなど同時更新される列を洗い出し、DB内計算+ロックになっているか確認する。
- DBの外への書き込み(キュー・外部API・別サービス)を列挙し、dual write になっている箇所を outbox に置き換える候補にする。
- リトライされ得るエンドポイントに冪等キーを導入する。
- 複数サービスにまたがる業務処理に Saga と補償 の設計があるか確認する。
「今は動いているから大丈夫」が一番危険なサインです。直列に近い今のうちに、同時実行を前提とした設計に倒しておくことをおすすめします。
FAQ
Q1. 楽観ロックと悲観ロック、どちらを使うべきですか?
A. 競合の頻度で選びます。競合が稀ならロック待ちが無い楽観ロック、在庫・残高のように競合が頻繁でやり直しコストが高いなら悲観ロック(SELECT ... FOR UPDATE)が向きます。
Q2. outbox パターンと、単にトランザクション後にキューへ送るのは何が違いますか? A. トランザクション後の送信は dual write そのもので、コミット後・送信前にプロセスが落ちるとイベントが欠落します。outbox はイベント記録を業務更新と同一トランザクションに含めるため、欠落が原理的に起きません。
Q3. eventual consistency は必ず避けるべきですか? A. いいえ。可用性とスケールのために結果整合は有効な選択です。問題は「書いたら即読める」前提のコードに混入することです。即時性が必要な箇所だけプライマリ読みにするなど、整合性の要件を箇所ごとに分けて設計します。
Q4. 2相コミット(2PC)は使ってはいけませんか? A. 禁止ではありませんが、コーディネータ障害時のロック残留や可用性低下のため、Web系では Saga + 冪等性 + outbox の組み合わせが選ばれる傾向にあります。強整合が業務要件として必須な領域では2PCも検討対象です。
Q5. 冪等キーはどこに持たせればよいですか? A. クライアントが生成する一意なキー(例: UUID)をリクエストに付与し、サーバ側で「処理済みキー」をDBに記録して重複を弾きます。記録と本処理を同一トランザクションに含めると、確実に二重処理を防げます。
References
-
Martin Kleppmann, "Designing Data-Intensive Applications"(O'Reilly, 2017)— https://dataintensive.net/
