代表的な面接トピック

バックエンド面接:楽観的並行性制御によるロストアップデートの防止方法

バックエンド普通
Offer.cc 編集チーム公開日 更新日

質問

2人のユーザーが請求書明細項目のバージョン7を読み込み、それぞれ編集して保存しようとしています。後からの古いリクエストが先に保存された結果を暗黙的に上書きしないようにするには、HTTP APIとデータベースの書き込みをどのように設計しますか?

問題と適用シナリオ

リソース invoice_items/{id} には説明(description)と金額(amount)が含まれています。AliceとBobは両者ともバージョン7を読み込みます。 Aliceが先に保存し、バージョン8を作成します。Bobはその後、依然としてバージョン7に基づいた編集内容を送信します。 無条件の UPDATE を実行すると、両方の呼び出し元に成功が返されながらBobがAliceの変更を上書きしてしまいます。これこそが、この問題で防ぐべきロストアップデート(lost update)です。

HTTP JSON API、条件付き更新をサポートするリレーショナルデータベース、および再利用されないリソースIDを前提とします。各レスポンスには正規のJSON表現が1つ存在します。ビジネスフィールドの変更ごとにリソースバージョンが進み、新しい強いETag(Strong ETag)が生成されます。競合は稀であると予想されるため、ユーザーが編集している間データベースロックを保持する代わりに楽観的並行性制御(Optimistic Concurrency Control)を採用します。PUTPATCH、および DELETE は対象範囲内ですが、リアルタイム協調編集アルゴリズムは対象外です。

コアコンピテンシーはバックエンド設計です。クライアントが読み込んだバージョン、HTTP事前条件、データベースのアトミックな書き込みを1つのテスト可能な規約として結び付けます。サンプルではPostgreSQLスタイルのSQLを使用しますが、他のデータベースでも同等の条件付き更新と影響を受けた行数(affected rows)のチェックが必要です。コントローラー内でバージョンを比較した後に無条件の更新を発行すると、チェックと書き込みの間に競合状態(Race Condition)が残ります。

面接官が評価しているポイント

第1の評価基準は、候補者がロストアップデートのインターリーブ(処理の交差)を示し、なぜLast Writer Wins(最後の書き込み優先)が必ずしも正しくないのかを説明できるかです。優れた回答では、編集可能な読み取りリクエストすべてでバージョンの証跡(witness)を返し、書き込みリクエストに対してそれが現在のバージョンに基づいていることを証明させます。

第2の評価基準は、正確なHTTPセマンティクスです。GET は強いETagを返し、変更リクエストはそれを If-Match で送信します。If-Match は強比較を行うため、古いタグや弱いタグ(Weak ETag)はパスできません。必要な事前条件が欠落している場合は 428 Precondition Required を返し、提供されたタグが一致しない場合は 412 Precondition Failed を返します。HTTP事前条件では表現されないビジネス上の競合には 409 Conflict を予約します。

第3の評価基準は、データベースのチェックと書き込みをアトミックに行うことです。idversion の両方を一致させ、ビジネスフィールドを更新し、1つのステートメントでバージョンをインクリメントします。影響を受けた行数が0であることは、リソースが存在しないか古くなっている状態を意味します。バージョンを読み取ってから無条件の書き込みを実行することはTOCTOU(Time of Check to Time of Use)競合になります。

最後に、競合からの復旧と境界の理解です。楽観的並行性は冪等性キー(Idempotency Key)の代わりにはならず、1つの行のバージョンは複数行にまたがる述語を保護しません。ホットなリソースが継続的に 412 を返す場合、設計はクライアントに永久に即時リトライを行わせるのではなく、競合戦略そのものを変更する必要があります。

回答前に明確にすべき質問

  • 競合のドメインはリソース全体ですか、それとも1つのフィールドですか? リソース全体のバージョン管理が最も証明しやすいですが、異なるフィールドへの編集でも競合が発生します。誤った競合(False Conflicts)が頻発する場合は、集約の縮小、明示的な操作API、またはフィールドレベルのマージルールの導入が正当化される場合があります。
  • どのミューテーションメソッドにバージョンが必要ですか? この問題では PUTPATCHDELETEIf-Match を要求します。インポート、バックグラウンドジョブ、管理スクリプトも、保護されていない書き込みパスを残すことなく、同じプロトコルに従う必要があります。
  • 誰が競合を解決しますか? 人間による編集の場合、元の値、提案された値、現在の値を表示してユーザーに選択させます。マシンの場合は、マージ関数がビジネスの不変条件を保持する場合にのみ再計算とリトライを行います。
  • 競合率とレイテンシの許容量はどのくらいですか? 衝突が稀であれば楽観的設計が適しています。アクセス集中する在庫やオークションのレコードでは、アトミックな単一ビジネス更新、短時間の悲観的トランザクション、またはシングルライターキューの方が適している場合があります。
  • タイムアウト後にクライアントが同じリクエストを再送信することはありますか? バージョンチェックは異なる意図の競合を処理します。冪等性キーは1つの意図の重複送信を処理します。設計には両方が必要になる場合があります。
  • バージョンは複数行にまたがるルールをカバーしていますか? 行のバージョンはそのリソースのみを保護します。「常に1人の医師がオンコールでなければならない」などのルールには、Serializableトランザクション、共通の行ロック、またはデータベース制約が必要です。

30秒の回答フレームワーク

「現在のリソースを強いETag(バージョン7の場合は \"invoice-item-42-v7\" など)とともに返します。クライアントは保存時にその値を If-Match に含めて送信します。必須ヘッダーがない場合サーバーは428を返し、タグが古い場合は412を返します。真の保護策はデータベースのアトミックなステートメントです。UPDATE ... WHERE id = ? AND version = 7 によってビジネスフィールドの変更とバージョンのインクリメントを同時に行います。更新行数が0の場合は古いか削除されたことを意味し、事前に確認してから無条件に更新するようなことは決してしません。成功時のレスポンスにはバージョン8のETagが含まれます。412の場合、クライアントは再取得してユーザーにマージさせます。タイムアウト後の再送は冪等性キーで別途カバーします。両方がバージョン7を読み込む2つのクライアントをテストし、確実に1つの書き込みのみが成功することを検証します。」

ステップバイステップの詳細解説

まずは誤った履歴から見ていきます。

text
Alice: GET item 42 -> amount=10000, version=7
Bob:   GET item 42 -> amount=10000, version=7
Alice: PUT amount=10500 -> unconditional UPDATE -> success
Bob:   PUT amount=9800  -> unconditional UPDATE -> success
Final: amount=9800; Alice's accepted update is lost

推奨される読み取りの規約は、強いETagを返すことです。これはクライアントが完全にそのままエコーしなければならない不透明な値(opaque value)です。機密情報を含んではならず、認証や認可の代わりになることはありません。

http
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "invoice-item-42-v7"

{"id":42,"description":"Consulting","amountCents":10000,"version":7}

Aliceはそのタグを If-Match に設定します。

http
PUT /invoice-items/42 HTTP/1.1
Content-Type: application/json
If-Match: "invoice-item-42-v7"

{"description":"Consulting","amountCents":10500}

サーバーは事前条件を評価する前に、通常の認証、認可、リクエストバリデーションを実行します。APIがすべての変更に条件付き更新を要求している場合、If-Match が欠落していれば428を返し、タグを付けて再取得および再送信するようクライアントに伝えます。提供された値が現在の強いETagでない場合、サーバーはリソースを変更せず412を返します。強比較では、両方のエンティティタグが弱くなく、不透明タグが文字単位で完全に一致することが求められます。したがって、W/"invoice-item-42-v7" をパスさせることはできません。

HTTPチェックの後、データベースは期待される同一バージョンをアトミックに強制する必要があります。

sql
UPDATE invoice_items
SET description = $1,
    amount_cents = $2,
    version = version + 1
WHERE id = $3
  AND version = $4
RETURNING id, description, amount_cents, version;

$4 は、検証済みETagからデコードされた期待されるバージョン7です。1行返された場合は成功を意味し、レスポンスにはバージョン8とその新しいETagが含まれます。0行の場合は、認可境界内で現在のレコードを読み取ります。リソースが存在しない場合は404を返し、まだ存在する場合は412を返します。IDが再利用されない前提により、削除と再作成によって元のリソースになりすますことを防ぎます。分類に関わらず、0行のパスで書き込みが行われることはありません。

SELECT version を実行して比較し、その後にバージョン述語のない UPDATE を続けるような実装は避けてください。それらのステートメントの間に別のトランザクションがコミットされる可能性があり、チェックを通過したばかりのリクエストが新しい状態を上書きしてしまいます。コントローラーがETagを比較したとしても、正確性の最終的な境界となるのは WHERE version = $4 です。ORMの並行性トークンは、同等の条件付き更新を生成し、影響行数0を並行性競合に変換する必要があります。

各ステータスとその原因を対応させます。

  • 428 Precondition Required: クライアントがこのAPIで必須とされている条件を指定しなかった。
  • 412 Precondition Failed: クライアントは If-Match を送信したが、現在の表現がそれを満たしていない。
  • 404 Not Found: 認可ポリシーに従い、リソースがもはや存在しない。
  • 409 Conflict: バージョンの事前条件は通過したが、確定済みで変更不可の請求書など、別のビジネス状態の競合が適用された。

412を受信した後、対話型クライアントは新しい GET を実行し、3つの値(ユーザーが最初に読み取った値、ユーザーが提案した値、サーバーが現在保持している値)を保持します。提案された変更は、現在の値がまだ元の値と等しいフィールドにのみ自動適用できます。その他のフィールドには明示的な競合判断が必要です。単一のリソースバージョンは、重複しない編集であっても保守的に拒否します。それが計測されたボトルネックになる場合は、暗黙的にLast Writer Winsに戻すのではなく、リソースを分割するか、POST /invoice-items/42/adjust-amount などの意図ベースの操作を公開してください。

冪等性キーは異なる障害を解決します。Aliceのバージョン7の更新がバージョン8としてコミットされたものの、成功レスポンスが失われたとします。単純なリトライを行うと、古くなった If-Match を送信することになります。不変の冪等性キーがあれば、サーバーは保存されている初回の結果を返すことができます。バージョンの証跡はバージョン7に基づく2つの異なる編集を検出し、冪等性レコードは1つの編集が2回送信されたことを認識します。これらは相互の代替品ではありません。サーバーが以前の実行を確実に証明できない場合、現在の似たデータから成功を推測するよりも412を返す方が安全です。

楽観的並行性は競合が少ないことを前提としています。リソースの競合率が持続的に高い場合、繰り返される読み取りや手動マージはキャパシティを浪費し、ユーザー体験を悪化させます。在庫の減算では、UPDATE ... SET stock = stock - $1 WHERE stock >= $1 を直接使用して単一行の不変条件を強制できます。短時間の「読み取り-判定-書き込み」操作では行ロックを使用できます。コマンドを順序通りに処理しなければならない集約は、単一のライターにパーティショニングできます。計測された競合率、操作が可換であるか、待機時間の許容量、不変条件のスコープに基づいて選択してください。

検証では、APIを順番に2回呼び出すのではなく、処理の交差(インターリーブ)を強制する必要があります。両方のクライアントにバージョン7を読み取らせ、バリアを介して異なる金額を同時に解放し、正確に1つの成功、1つの412、最終バージョンが8であること、勝者のコンテンツが反映されていることをアサートします。また、If-Match 欠落時の428返却、弱いタグの失敗、新しいETagを含む成功レスポンス、古いタグが古いまま維持されること、更新と削除の競合、レスポンス喪失後の同一冪等性キーによるリプレイなどもテストします。本番環境では、事前条件欠落率、412発生率、自動リトライ回数、エンドポイントごとの最終離脱率を監視します。急激な増加は、通常、ホットスポットや更新を行っていないクライアントの存在を示しています。

質の高い模範解答

「競合のドメインを請求書の1明細項目に設定します。両ユーザーともバージョン7を保持しているため、後からの保存が無条件に最初の保存を上書きしてはなりません。編集可能な読み取りはすべて強いETagを返し、PUTPATCHDELETE はすべてそれを If-Match でエコーする必要があります。ヘッダーがない場合は428を受け取り、古いタグは書き込みを行わずに412を受け取ります。

HTTP層での比較だけでは不十分です。リソースごとのバージョンを保存し、永続化層で UPDATE invoice_items SET ..., version = version + 1 WHERE id = ? AND version = ? RETURNING ... をアトミックに実行します。 Aliceがバージョン7で成功した後、行はバージョン8になります。Bobの同じ述語は0行に影響するため、Aliceを上書きすることはできません。成功時は新しいETagを返します。0行の場合、認可を考慮した読み取りによって404と412を区別しますが、どの分岐でも無条件更新にフォールバックすることはありません。

412が発生した場合、クライアントは再取得して元の値、提案された値、現在の値を比較し、ユーザーが実際の競合を解決できるようにします。無関係なフィールド同士の衝突が頻繁に発生する場合は、リソースを分割するか、意図ベースのアトミック操作を設計します。タイムアウトのリトライには別途冪等性キーを使用します。これは1つの意図の重複送信を認識するものであり、バージョンは同じ古い状態に基づく異なる意図を認識します。

2つの接続とバリアを使用して、両者が送信前にバージョン7を読み込むようにします。正確に1つが成功し、もう1つが412を受け取り、最終バージョンが8になる必要があります。また、If-Match の欠落、弱いタグ、削除との競合、失われたレスポンスのリプレイもテストします。本番環境で412率が高いままの場合は、リトライを無制限に増やすのではなく、条件付きビジネス更新、短時間のロック、またはシングルライター構成を検討します。」

よくある間違い

  • 更新前にアプリケーションコードでバージョンをチェックする → チェックと書き込みの間に別のコミットが入り込む可能性がある → 同一の UPDATE 述語にバージョンを含め、影響を受けた行数を検証する。
  • If-Match が欠落していても処理を続行する → 保護されたクライアントと、データを破壊する可能性のあるレガシークライアントが共存してしまう → 条件付き更新をAPI規約の一部とし、欠落時は428を返す。
  • If-Match に対して弱いETagを受け入れる → 標準規格では強比較が要求されるため、弱いタグは一致しない → 編集可能な表現に対して意味的に有効な強いETagを生成する。
  • すべての競合に対して409を返す → クライアントがHTTP事前条件の失敗とビジネス状態の競合を区別できない → 失敗したバージョンの事前条件には412を返し、独立したビジネス競合には409を予約する。
  • 412の後に同一リクエストを自動的に再送信する → 古いタグは依然として古いままとなり、黙って置き換えると別の編集を上書きするリスクがある → 再取得して再計算するか、ユーザーにマージを促す。
  • 冪等性キーが並行上書きを防ぐと思い込む → 2つの異なる編集は異なるキーを使用するが、同一の古いベースを共有する可能性がある → 重複送信には冪等性を、並行する意図にはバージョン条件をそれぞれ使用する。
  • すべての行のバージョンがあらゆる不変条件を保護すると主張する → 複数行にわたる書き込みスキュー(Write Skew)は、単一行のバージョンでは競合しない → 不変条件のスコープに応じて、Serializable、共通の競合ポイント、または制約を選択する。
  • ホットスポットに対して制限なく即時リトライする → 失敗したリクエストが再度衝突し、負荷を増幅させる → 競合を測定し、アトミック操作、短時間のロック、またはシングルライターを検討する。

フォローアップの質問と回答

フォローアップ1:異なるフィールドに対する2つの PATCH リクエストは競合すべきですか?

リソース全体のバージョンはどのフィールドの変更でも進むため、独立したパッチでも競合します。これは安全で説明しやすいデフォルトの挙動です。誤った競合が多数発生するという証拠がある場合は、独立して変化するライフサイクルをサイブリソースに分割するか、フィールドのベースラインを送信して3方向マージを定義します。フィールドレベルのトークンは誤った競合を減らしますが、トークンの状態が増加し、複合的な不変条件や監査が複雑になります。単に412レスポンスを抑えるためだけに追加すべきではありません。

フォローアップ2:クライアントがタイムアウト後にリトライし、古いETagが失効している場合はどうなりますか?

固定の冪等性キーを送信し、キー、リクエストダイジェスト、期待されるバージョン、初回の結果を一緒に永続化します。同じキーとリクエストによるリプレイには初回の結果を返し、同じキーで異なるリクエストの場合は拒否します。信頼できる冪等性レコードがない場合、単に似ているように見える現在のフィールドがあっても、最初のリクエストが成功した証拠にはなりません。412を返し、クライアントに現在の状態を確認させます。

フォローアップ3:updated_at をバージョントークンとして使用できますか?

データベースがそれを生成し、関連するすべての変更でそれが更新され、その精度が連続する書き込みを区別でき、すべてのノードが同じセマンティクスを与える場合にのみ安全です。タイムスタンプの精度の切り捨てや、更新をバイパスするパスがあると、2つのバージョンが同じ値を持つ可能性があります。リソースごとの整数またはデータベース固有の行バージョンの方が、一般的に曖昧さの少ない等価性チェックが可能であり、不透明なETagとしてもエンコードできます。

フォローアップ4:高競合下では設計をどのように変更すべきですか?

まず、412レスポンスをリソースおよび操作ごとにグループ化して、1つのホットキー、過度に広い競合ドメイン、長期間オフラインのクライアントを切り分けます。可換な加算・減算の意図はアトミックなデータベース式に変換します。短時間の非可換な判定には行ロックを使用するか、厳密に順序付けられたコマンドをリソースキーごとに1つのライターにルーティングします。各選択肢は、拒否される書き込みを減らす代わりに待機時間、スループット、またはアーキテクチャの複雑さとトレードオフになるため、測定された競合とレイテンシのデータに基づいて変更を実施すべきです。

フォローアップ5:なぜ1行のバージョンでは書き込みスキュー(Write Skew)を防げないのですか?

書き込みスキューでは、2つのトランザクションが同一の複数行述語を読み取り、異なるレコードを更新するため、両方の行バージョン条件が通過してしまう可能性があります。各トークンは更新対象の行が変更されていないことのみを証明し、共有された述語については何も保証しません。不変条件を共通のカウンター行に投影するか、共通のガードをロックするか、適切なデータベース制約を追加するか、あるいはSerializableを使用して直列化不可能な依存関係を検出してください。

公開情報ソース

関連する質問