なぜ「何度押しても1つだけ」にならないのか?POSTメソッドの非冪等性と実務での防衛策
ネットワークの世界には、美しくも残酷なルールが存在する。
私たちが日々何気なく叩いているWeb API。その中でも、リソースの新規作成を担う POST メソッドの挙動について、本質を理解しているエンジニアはどれほどいるだろうか。
「ボタンを連打したら、同じデータが何重にも登録されてしまった」
「タイムアウトしたからリトライしたら、課金処理が二重走走(ダブ)って大炎上した」
インフラの現場や開発のレビューで、こんな悲劇に直面したことはないだろうか。教科書には「POST は非冪等(ひべきとう)である」の一言で片付けられがちだが、この「非冪等」という性質の裏側では、TCPのパケット、ロードバランサーのリトライ機構、そしてアプリケーションのデータベーストランザクションが複雑に絡み合っている。
今回は、RFCの仕様に立ち返りつつ、実務の現場でこの「非冪等性」とどう向き合い、いかにしてシステムを守るべきか、シニアエンジニアの視点から徹底的に解説しよう。
—
1. RFCが定義する「非冪等(Non-idempotent)」の正体
まず、プロトコルの大本であるRFC 7231(およびその後継のRFC 9110)を確認しておこう。
HTTPメソッドの特性を語る上で避けて通れないのが「冪等性(Idempotency)」だ。冪等とは、数学用語に由来し、「ある操作を1回行っても、複数回(2回以上)繰り返し行っても、システムの最終的な状態が同じであること」を指す。
GET,PUT,DELETE:冪等(何度実行しても結果は同じ)POST:非冪等(何度実行するかによって結果が変わる)
POST リクエストが送信されると、サーバー側は通常、新しいリソースをコレクションの下位に作成する。例えば、/api/v1/orders というエンドポイントに対して注文データを POST 送信すると、サーバーは新しい一意なID(例: order_id: 12345)を発行し、データベースにレコードを挿入する。
もし、ネットワークの瞬断など何らかの理由でクライアントがレスポンスを受け取れず、同じ POST リクエストをもう一度送ってしまったらどうなるか? サーバーは「新しい注文がもう1件来た」と解釈し、今度は order_id: 12346 を発行して別のレコードを作成してしまう。
これが、POST が非冪等たる所以であり、実務において最も恐れられているデータの二重登録問題の正体なのだ。
—
2. 通信フロー:なぜ二重作成が起きるのか
言葉だけではイメージしにくいので、実際の通信とインフラレイヤーで何が起きているのか、シーケンスを見てみよう。
[Client / Browser] [Reverse Proxy / LB] [API Server / DB]
| | |
|---- (1) POST /orders ------->| |
| (Body: 注文データ) |---- (2) POST /orders ------>|
| | (転送) |--+
| | | | DB INSERT
| | |<-+ (ID: 1001)
| | |
| (ここでタイムアウト!) | |
|X----------------------------| |
| | |
|---- (3) リトライ: POST ------>| |
| (同じ注文データ) |---- (4) POST /orders ------>|
| | |--+
| | | | DB INSERT (再実行)
| | |<-+ (ID: 1002)
| | |
| | | ← 致命傷:
| | | 同じ注文が
| | | 2つ生まれる!
リバース Nginx や AWSの Application Load Balancer (ALB) などのインフラ層では、バックエンドからの応答がない場合に自動リトライを行う設定になっていることがある。また、クライアント側のJavaScript(Fetch APIやAxios)でも、ネットワークエラー時に自動リトライを実装しているケースは多い。
このとき、POST メソッドに対して安易なリトライを行うと、サーバー側で意図しない重複処理が走ってしまうのだ。
—
3. 実装例:PythonとJavaScriptで見る非冪等なPOSTの現実
実際にコードを書いて、この挙動を確認してみよう。ここでは、Pythonの requests ライブラリを用いて、同じ POST リクエストを意図的に2回送信する例を示す。
Pythonによるリクエストの重複実行(非冪等性の確認)
import requests
import json
# APIエンドポイントのURL
url = "https://api.example.com/v1/orders"
# 送信データ(ペイロード)
payload = {
"item_id": "SKU-9988",
"quantity": 2,
"user_id": "usr_abc123"
}
# 共通のHTTPヘッダー
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
print("--- 1回目のPOSTリクエスト送信 ---")
response_1 = requests.post(url, data=json.dumps(payload), headers=headers)
print(f"ステータスコード: {response_1.status_code}")
print(f"レスポンスボディ: {response_1.text}")
# ここで新規リソース(例: order_id: 501)が生成される
print("\n--- 2回目のPOSTリクエスト送信(全く同じ内容) ---")
response_2 = requests.post(url, data=json.dumps(payload), headers=headers)
print(f"ステータスコード: {response_2.status_code}")
print(f"レスポンスボディ: {response_2.text}")
# サーバー側の実装が素朴な場合、ここでさらに新規リソース(例: order_id: 502)が生成されてしまう!
このコードを実行すると、サーバー側には全く同じ内容の注文が2件登録されることになる。これが、POST メソッドの本質的な挙動だ。
—
4. 現場の防衛策:非冪等なPOSTを「安全」にするテクニック
「では、POST を使うときは二重登録を諦めるしかないのか?」
もちろん、そんなことはない。シニアエンジニアが現場で必ず実装している、非冪等な処理を安全に行うための定石を紹介しよう。
① 冪等キー(Idempotency Key)の導入
現代のモダンなWeb API(Stripeの決済APIやAWSの各種APIなど)で標準的に採用されているのが、冪等キーの仕組みだ。
クライアント側でUUIDなどを生成し、それをカスタムヘッダー(一般的には Idempotency-Key や X-Idempotency-Key)に付与してリクエストを送る。サーバー側では、受け取ったキーを一定期間(例: 24時間)RedisなどのKVSやデータベースのユニーク制約付きテーブルに保存する。
- 1回目のリクエスト:キーが存在しないため、通常通り処理を実行し、結果をキーとともにキャッシュする。
- 2回目のリクエスト(リトライ等):同じキーが存在するため、実際の処理(DBのINSERT等)は行わず、キャッシュしておいた1回目のレスポンスをそのまま返す。
Python (Flask) による冪等キー実装のイメージ
from flask import Flask, request, jsonify
import uuid
app = Flask(__name__)
# 一時的なストレージ(実務ではRedis等を使用)
idempotency_cache = {}
@app.route('/v1/orders', methods=['POST'])
def create_order():
# クライアントから冪等キーを取得
idempotency_key = request.headers.get('Idempotency-Key')
if not idempotency_key:
return jsonify({"error": "Idempotency-Key header is required"}), 400
# すでに処理済みのキーかチェック
if idempotency_key in idempotency_cache:
print(f"[Info] 冪等キー重複検知: {idempotency_key}. キャッシュされたレスポンスを返します。")
cached_response = idempotency_cache[idempotency_key]
return jsonify(cached_response["body"]), cached_response["status"]
# --- ここから本来の新規作成処理 ---
data = request.json
new_order_id = str(uuid.uuid4())
response_data = {
"order_id": new_order_id,
"status": "created",
"item_id": data.get("item_id")
}
# 結果をキャッシュに保存
idempotency_cache[idempotency_key] = {
"status": 201,
"body": response_data
}
# ---------------------------------
return jsonify(response_data), 201
if __name__ == '__main__':
app.run(port=5000)
② データベースのユニーク制約の活用
APIサーバーのロジックだけでなく、ストレージ層でも防衛線を張るべきだ。
例えば、ユーザーIDと「今日の日付+注文ハッシュ」などを組み合わせたカラムに UNIQUE 制約を張っておく。これにより、アプリケーションの不具合や競合状態で万が一重複処理が走り抜けたとしても、データベースのレイヤーで一意性違反(Integrity Error)として弾き返すことができる。
③ クライアント側(UI/UX)での制御
インフラやバックエンドだけでなく、フロントエンド側の工夫も極めて重要だ。
「注文確定ボタン」が押された瞬間にボタンをグレーアウトして無効化する(ダブルクリック防止)、あるいはリクエスト送信中にスピナー(ローディングアニメーション)を表示してユーザーに余計な操作をさせないといった配慮は、非冪等な操作を扱う上での基本中の基本である。
—
5. まとめ
今回は POST メソッドの非冪等性について、プロトコルの仕様から実務における防衛策まで深く掘り下げて解説した。
POSTは「リソースの新規作成」を行う非冪等なメソッドであり、複数回実行すると複数のリソースが生成される。- ネットワークのタイムアウトやLBのリトライによって、意図しない二重送信(二重課金や二重登録)が発生するリスクが常に伴う。
- 実務では、
Idempotency-Keyヘッダーを活用したサーバー側の重複排除ロジックや、DBのユニーク制約を組み合わせることで、堅牢なシステムを構築する必要がある。
「動けばいい」で作られたAPIは、障害時に牙をむく。プロトコルの仕様を正しく理解し、耐障害性の高い美しいAPI設計を心がけてほしい。あなたの書くコードが、夜間の不要なアラートからオペレーターを救うことになると信じている。
コメント