APIの上書き事故を防ぐETag設計|If-Match・412・楽観ロックの実務

「APIの上書き事故を防ぐETag設計|If-Match・412・楽観ロックの実務」の内容を表す技術イラスト

APIで同じレコードを複数の利用者や処理が更新すると、先に保存された変更を、古いデータを持つ別の更新が消してしまうことがあります。これは「更新消失(lost update)」と呼ばれる競合です。

ETagとIf-Matchを使うと、クライアントが取得した版と、更新時点の最新版が同じ場合だけ変更を受け付けられます。この記事では、HTTPの条件付きリクエストをSQLiteのversion列へつなぎ、競合を検出して安全に再取得・再編集する設計を整理します。

目次

更新消失はどのように起きるか

同じ部品データをAさんとBさんが編集する例を考えます。

Aがversion 7を取得
Bがversion 7を取得
Bが材質を変更してversion 8として保存
Aが古いversion 7を基に名称を変更して保存

Aの更新が無条件に通ると、Bが変更した材質までversion 7の値へ戻る可能性があります。最後に保存した人が常に勝つlast write winsは単純ですが、他者の変更を知らせず消してしまいます。

必要なのは、更新直前に「取得時から対象が変わっていないか」を確認し、変わっていれば自動上書きせず競合として返す仕組みです。

ETagは表現の版を示すvalidator

RFC 9110では、ETagを選択されたrepresentationのvalidatorとして定義しています。ETagは二重引用符で囲まれたopaqueな値です。

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "asset-42-v7"

{
  "id": "asset-42",
  "name": "Hydraulic unit A",
  "material": "SS400",
  "version": 7
}

ETagは秘密情報や認可tokenではありません。クライアントは値の内部構造を解釈せず、そのまま次のrequestへ渡します。

生成方法はAPI側が決めます。代表例は次のとおりです。

  • DBのversion番号から生成する
  • 保存済みrepresentationのhashから生成する
  • 改訂IDなど、不透明で変更ごとに変わる値を使う

hashを使う場合は、key順や空白が変わるJSONの再serializeへ無造作に適用せず、どのbyte列を対象にするか固定します。version番号を使う場合も、response内容が変わるすべての更新でversionを増やします。

強いETagと弱いETagを使い分ける

ETagには強いvalidatorと、W/で始まる弱いvalidatorがあります。

強いETag: "asset-42-v7"
弱いETag: W/"asset-42-v7"

弱いETagは、意味上は同等でもbyte単位では同一でないrepresentationを扱えます。キャッシュ検証には役立ちますが、更新消失を防ぐIf-Matchには適しません。

RFC 9110では、If-Matchの比較にstrong comparisonを使うよう定めています。強いETag同士は両方がweakでなく、opaque-tagが文字単位で一致する場合だけ一致します。更新APIでは強いETagを発行します。

If-Matchで更新条件を送る

クライアントはGETで受け取ったETagを、PUTやPATCHのIf-Match headerへ設定します。

PATCH /assets/asset-42 HTTP/1.1
Content-Type: application/json
If-Match: "asset-42-v7"

{
  "name": "Hydraulic unit B"
}

受信側は、変更を適用する前に現在のETagと比較します。

If-Matchが現在のETagと一致
  → 更新を実行し、新しいETagを返す

If-Matchが現在のETagと不一致
  → 更新せず412 Precondition Failedを返す

If-Match: *は、対象に現在のrepresentationが存在する場合だけ条件を満たします。「存在するときだけ変更する」操作に使えます。一方、新規作成時に「まだ存在しないこと」を条件にする場合はIf-None-Match: *が候補です。

412・428・409の役割を分ける

競合時のstatus codeを一つにまとめると、クライアントが次の行動を判断できません。

status条件クライアントの対応
428 Precondition Required更新APIが条件付きrequestを必須としているが、If-MatchがないGETで最新版とETagを取得して再送
412 Precondition FailedIf-Matchはあるが現在のETagと一致しない最新版を取得し、差分を確認して再編集
409 ConflictETagとは別の業務状態が更新を許可しない状態遷移や入力内容を見直す

RFC 6585の428は、サーバーが条件付きrequestを要求していることを示します。RFC 9110の412は、与えられた事前条件が偽だった場合に使います。

たとえば「承認済み図面は編集不可」は業務ルール上の競合です。ETagが一致していても、状態によって409を返す場合があります。認証失敗や入力形式エラーを競合として扱わないことも重要です。

DBのversion条件と一つの更新にする

APIでETagを比較してから、別のSQLで無条件UPDATEすると、比較と更新の間に別処理が割り込めます。確認と更新を一つのSQLへまとめます。

CREATE TABLE assets (
    id         TEXT PRIMARY KEY NOT NULL,
    name       TEXT NOT NULL,
    material   TEXT NOT NULL,
    version    INTEGER NOT NULL DEFAULT 1,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

更新SQLでは、IDだけでなくクライアントが取得したversionもWHERE条件に含めます。

UPDATE assets
SET name = ?,
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND version = ?;

SQLiteのUPDATE仕様では、WHEREのboolean式が真のrowだけが更新され、一致するrowがなくてもSQL errorにはなりません。そのため、更新row数が0なら「対象がない」または「versionが変わった」と判定できます。

この条件付きUPDATEが楽観ロックです。長時間DBをlockしたまま利用者に編集させず、保存時に競合を検出します。

PythonとSQLiteで最小実装する

次の関数は、ETagから期待versionを受け取り、条件付きUPDATEを実行します。

import sqlite3


class PreconditionFailed(Exception):
    pass


class AssetNotFound(Exception):
    pass


def parse_etag(etag: str, asset_id: str) -> int:
    prefix = f'"{asset_id}-v'
    if not etag.startswith(prefix) or not etag.endswith('"'):
        raise ValueError("ETagの形式が不正です")

    version_text = etag[len(prefix):-1]
    if not version_text.isdigit():
        raise ValueError("ETagのversionが不正です")
    return int(version_text)


def update_asset_name(
    conn: sqlite3.Connection,
    asset_id: str,
    new_name: str,
    if_match: str,
) -> str:
    if not new_name.strip():
        raise ValueError("nameは空にできません")

    expected_version = parse_etag(if_match, asset_id)

    cursor = conn.execute(
        """
        UPDATE assets
        SET name = ?,
            version = version + 1,
            updated_at = CURRENT_TIMESTAMP
        WHERE id = ? AND version = ?
        """,
        (new_name.strip(), asset_id, expected_version),
    )

    if cursor.rowcount == 0:
        exists = conn.execute(
            "SELECT 1 FROM assets WHERE id = ?",
            (asset_id,),
        ).fetchone()
        conn.rollback()
        if exists is None:
            raise AssetNotFound(asset_id)
        raise PreconditionFailed("取得後にデータが更新されています")

    new_version = expected_version + 1
    conn.commit()
    return f'"{asset_id}-v{new_version}"'

Web framework側では、If-Matchが欠けていれば428、AssetNotFoundなら404、PreconditionFailedなら412へ対応させます。成功時は更新後のrepresentationと新しいETagを返します。

SQL parameter bindingを使い、ETagや入力値をSQL文字列へ直接連結しません。versionの照合と加算を同じUPDATEで行うため、同じversionを持つ二つの更新が同時に来ても、成功するのは先に条件を満たした一方だけです。

正常系と競合系をテストする

最低限、次のケースを自動テストへ含めます。

正常: version 7にIf-Match "asset-42-v7" → 更新成功、version 8
競合: version 8にIf-Match "asset-42-v7" → 412、値は変更しない
欠落: If-Matchなし → 428
不正: weak ETagまたは壊れたETag → 拒否
不存在: 対象IDがない → 404
業務競合: 承認済みで編集不可 → 409

AとBが同じETagで更新する並行テストも必要です。Aが成功した後、Bが412になり、Aの変更が残っていることを確認します。

412を受けたクライアントの処理

412を受けた後、古いrequestを新しいETagへ付け替えて自動再送すると、競合検出を無効化してしまいます。

基本の復旧手順は次のとおりです。

最新版と新しいETagをGET
  ↓
取得時の値・利用者の変更・最新版を比較
  ↓
自動merge可能なfieldだけ統合
  ↓
利用者が競合を確認
  ↓
新しいETagで更新

異なるfieldの変更でも、resource全体のETagなら競合になります。安全性は高い一方、更新頻度が高いresourceでは競合が増えます。その場合はresourceを責務ごとに分ける、専用の操作endpointを用意する、field単位のmerge規則を定義する、といった見直しが必要です。

ETagだけでは重複実行を防げない

ETagは「取得後に他の変更があったか」を検出します。通信切断による同一requestの再送や、「数量を1増やす」の二重実行を識別する仕組みではありません。

  • 同時更新の検出:ETagとIf-Match
  • 同じ命令の重複排除:request IDやidempotency key
  • 業務上の一意性:DBのUNIQUE制約
  • 更新順序:versionやsequence

役割を分けて組み合わせます。前回の応答を受け取れなかった再送を2xx扱いにするか、常に412にするかもAPI契約で固定します。

日時だけをvalidatorにしない

updated_atを比較する方法もありますが、時刻の精度、timezone、同じ時刻内の複数更新、DBとアプリの時計差を考慮する必要があります。HTTPにはIf-Unmodified-Sinceもありますが、RFC 9110ではIf-Matchをより正確な条件として扱っています。

日時は監査や表示に残し、同時更新の判定には更新ごとに確実に変化するversionまたは強いETagを使う方が明確です。

セキュリティと公開範囲

ETagは認可の代わりになりません。正しいETagを知っていても、対象を更新する権限がなければ拒否します。

また、ETagへ顧客番号、担当者、機密状態などを埋め込まないようにします。連番を公開すると更新頻度を推測される可能性がある場合は、不透明な値やhashを検討します。ただし、値を隠すこととアクセス制御は別の問題です。

ログにはresource ID、受信ETag、現在version、結果を残せますが、payload内の機密値や認証情報をそのまま記録しません。

CAD・設計データ連携への展開

図面属性、部品マスター、計算条件、BOMは複数の画面や連携処理から更新されます。各resourceに安定したIDとversionを持たせれば、古いCSV取込や遅れて届いた自動処理が最新データを無条件に戻す事故を防げます。

CSVにはHTTP headerがないため、交換用データへsource_version列を追加し、取込時の条件付きUPDATEへつなげます。

id,name,material,source_version
asset-42,Hydraulic unit B,SS400,7

API、CSV、DBで同じversion契約を共有すれば、入口が違っても競合判定を統一できます。

まとめ

ETagとIf-Matchは、APIのcache機能だけでなく、複数の更新が互いを消す事故を防ぐために使えます。

  • GET responseで強いETagを返す
  • 更新requestにIf-Matchを必須化する
  • ETag不一致では更新せず412を返す
  • 条件header欠落には428を使える
  • 業務状態の競合は409と分ける
  • DBではIDとversionを同じWHERE条件へ入れる
  • 更新row数0を競合として扱う
  • 412後は最新版を取得し、差分を確認してから再送する
  • 重複実行には別途idempotency keyを使う

HTTPのvalidatorとDBのversionを一つの契約として設計すれば、API、CSV、Python、CAD連携の更新を、安全に再試行できるデータ資産へ変えられます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次