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 Failed | If-Matchはあるが現在のETagと一致しない | 最新版を取得し、差分を確認して再編集 |
409 Conflict | ETagとは別の業務状態が更新を許可しない | 状態遷移や入力内容を見直す |
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連携の更新を、安全に再試行できるデータ資産へ変えられます。

