2026年10月26日確認。古いPHPフォームで、送信後に画面がタイムアウトした。利用者へ「もう一度送ってください」と案内する前に、受付が保存されていないと判断できるでしょうか。
結果が分からないPOSTは、そのまま再送しない。まず同じ操作を識別するキーで受付を照合する。 保存済みなら、その受付番号を案内します。見つからなくても、元の処理が進行中かもしれません。サーバー側で二重保存を防げる場合に限り、同じキー・同じ内容で再試行する、という順序を提案します。
以下は架空データによる設計・個人ローカル検証です。CodexがPython+SQLiteで受付部分のモデルを作成・実行しました。PHP/PDO、実際のHTTP通信、メール配達、本番運用は検証していません。
タイムアウトは、受付失敗の証拠にならない
受付のINSERTとcommitが済んだ直後、応答だけが失われる場合を考えます。画面には結果が届かなくても、DBには1件残ります。そこで別のPOSTを送れば、実装次第では2件目が保存されます。
RFC 9110 §9.2.2も、非冪等なリクエストを自動再試行するには、その操作が実際には冪等であると分かるか、元のリクエストが適用されなかったと検出できることを条件にしています。「POSTだから必ず禁止」「タイムアウトだから再送」のどちらでもありません。RFC 9110
次の図は、今回の受付モデルから考えた照合手順です。「見つからない」と「保存されなかった」を同じ扱いにしないことが要点です。
ボタンの連打防止だけでは足りない
送信ボタンを無効にすることや、成功後に別ページへ移すことは、画面操作による重複を減らす補助になります。ただし、応答が失われた後の再送や、別タブからの同時送信までDB側で防ぐ仕組みにはなりません。
| 選択肢 | 選べる条件 | 残る作業・制約 |
|---|---|---|
| 現状維持し、自動再送を止める | 改修権限や受付照合の仕組みがない | 未確認表示と担当者への連絡経路を用意する |
| 担当者が受付を照合する | 件数が少なく、権限を持つ担当者が確認できる | 時刻・内容だけの照合では同一操作と断定しにくい |
| 既製フォームへ置き換える | 受付ID、重複時の動作、照会方法を確認できる | 製品ごとの再試行仕様、データ移行、運用費を調べる |
| 既存PHPへ受付キーとDB制約を追加する | 保存処理を変更でき、回帰確認と復旧を担当できる | キーの寿命、同時実行、照会権限、DB差異の検証が必要 |
小規模なフォームなら、まず自動再送を止めて手動照合を整える選択もあります。改修する場合も、サイト全体を刷新する前に、受付保存の境界だけを調べられます。どの案が安いかは、現状調査・実装・試験・継続照合の工数を測るまで未確定です。
同じ受付を返すために、何を保存するか
今回のモデルでは、受付行に次の情報を一緒に保存します。
- 操作の範囲を示す
scope。本番では認証済み利用者やサーバーが管理するセッション等から決める。 - 送信開始前に発行し、その操作の再試行で保持する
request_key。 - 検証済み入力を一定の形式にそろえた
digest。 - 保存済みの
id。
(scope, request_key)へUNIQUE制約を置き、受付とキーを同じ行でcommitします。重複時は既存行を読み、内容が同じなら同じ受付番号を返す。内容が違えば競合として止めます。先にSELECTして「なければINSERT」するだけでは、同時送信の間に別のINSERTが入れます。
SQLiteのREPLACEは、一意制約に衝突した既存行を削除してから追加する動作です。今回のように元の受付を保ちたい処理には使いません。SQLite公式:ON CONFLICT
このキーは認証やCSRF対策の代わりにはなりません。照会時も権限を確認し、キーを知っているだけの第三者へ問い合わせ本文を返さない設計にします。内容ハッシュも匿名化を保証しないため、本文やハッシュを無制限にログへ残さない前提です。
架空データで、保存後の応答消失を確かめる
PHP環境がないため、Python標準ライブラリのsqlite3で保存部分をモデル化しました。以下の短い再現コードで、保存後に応答消失を模擬する例外を発生させ、再試行後の受付を読み直せます。実際の通信障害を起こすコードではありません。
repro.pyとして保存し、python3 repro.pyで実行します。キーとscopeは説明用の固定値です。本番の発行・保持・認証処理は含みません。
import hashlib
import json
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE naive (id INTEGER PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE receipts (
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, request_key TEXT NOT NULL,
digest TEXT NOT NULL, payload TEXT NOT NULL, UNIQUE(scope, request_key)
);
""")
def submit(scope, key, payload, lose_response=False):
if any(not isinstance(v, str) or not v.strip() for v in (scope, key)):
raise ValueError("invalid scope/key")
if (not isinstance(payload, dict) or set(payload) != {"subject", "body"}
or any(not isinstance(v, str) for v in payload.values())):
raise ValueError("invalid form fields")
text = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
try:
with db: # Commit the receipt and its scoped key together.
receipt_id = db.execute(
"INSERT INTO receipts(scope, request_key, digest, payload) VALUES(?,?,?,?)",
(scope, key, digest, text)).lastrowid
except sqlite3.IntegrityError:
row = db.execute("SELECT id,digest,payload FROM receipts WHERE scope=? AND request_key=?",
(scope, key)).fetchone()
if row is None:
raise
if (row[1], row[2]) != (digest, text):
raise ValueError("same key, different input") from None
receipt_id = row[0]
if lose_response: # Local exception AFTER commit; no network involved.
raise ConnectionError("simulated lost response")
return receipt_id
payload = {"subject": "Demo", "body": "Fictional data"}
try:
with db:
db.execute("INSERT INTO naive(body) VALUES(?)", (payload["body"],))
raise ConnectionError("simulated lost response after commit")
except ConnectionError:
with db: # Blind retry creates a second receipt.
db.execute("INSERT INTO naive(body) VALUES(?)", (payload["body"],))
assert db.execute("SELECT COUNT(*) FROM naive").fetchone()[0] == 2
try:
submit("demo-user", "submission-a", payload, lose_response=True)
except ConnectionError:
pass
original = db.execute("SELECT id,digest,payload FROM receipts").fetchone()
assert submit("demo-user", "submission-a", payload) == original[0]
try:
submit("demo-user", "submission-a", {**payload, "body": "Changed"})
except ValueError as error:
assert str(error) == "same key, different input"
else:
raise AssertionError("changed input was accepted")
assert db.execute("SELECT id,digest,payload FROM receipts").fetchall() == [original]
print("PASS: blind retry=2 rows; same key/input=1 row and same ID; changed input rejected")
db.close()
出力は次のとおりでした。
PASS: blind retry=2 rows; same key/input=1 row and same ID; changed input rejected
Python 3.11.8/SQLite 3.51.0で確認しました。掲載コードが再現するのは表の1〜3です。別のファイルDB・独立接続を使ったローカルfixtureでは4〜6も実行し、6ケースすべてPASSでした。
| # | 与えた条件 | 実際に確認した結果 |
|---|---|---|
| 1 | commit後に応答消失を模擬し、キーなしで再送 | 受付が2件になる |
| 2 | commit後の応答消失から、同じキー・内容で再送 | 受付は1件、同じ受付IDを返す |
| 3 | 同じキーで本文だけ変更 | 拒否し、元の受付と内容を保持する |
| 4 | commit前に例外を起こしrollback、その後同じキーで再送 | rollback後0件、再送後1件 |
| 5 | 2つの独立DB接続から同じキー・内容を同時送信 | 受付は1件、両方に同じIDを返す |
| 6 | 元のINSERTが未commitの間に別接続で照合 | 見つからず未確認。commit後は同じ受付を確認できる |
補助確認では、scopeが異なれば同じキーでも別受付になることと、JSONのキー順が違っても同じ内容として比較できることを確かめました。ただし、この固定フィールドの文字列比較が、添付ファイルや数値の表記揺れまで扱えるわけではありません。
ケース6では、照合が空でも、その後に元の処理がcommitしました。タイムアウトを一律に失敗へ変換しない理由を、この範囲のモデルで確認できました。同時送信は2接続・小さなデータでの1回の試験で、負荷試験やDB製品間の互換性確認ではありません。
PHPへ反映する前に残る確認
このモデルの1件保存を、そのままPHPフォームの保証にはできません。PDOが利用できても、DB・ストレージエンジンによってトランザクションの条件が異なります。PHP公式も、ドライバーが対応していても実行条件によってトランザクションを利用できない場合や、DDLで暗黙commitが起こるDBを挙げています。PHP公式:トランザクション
実装担当は、対象DBの一意制約、重複時の例外、rollback後の読取り、接続断時のcommit結果を試験します。DBロックや接続失敗を、重複キーの成功へ丸めないことも確認点です。照会先が読み取りレプリカなら、反映遅延で見つからない可能性を含めます。
運用担当と決めたいのは、次の4点です。
- キーの保持期間:期間を過ぎた再送は重複を防げない可能性がある。期限切れやセッション消失を、新しいキーでの再送へ自動変換しない。
- 保留の担当:受付なし・DB照会失敗・処理中が続く場合に、誰が何を照合するか。一定時間経っただけでは失敗確定にしない。
- 通知の扱い:受付の重複防止はメールや外部サービスの二重送信防止を含まない。受付と通知の状態を分け、必要なら通知待ち記録と送信結果の照合を設計する。
- 監視と戻し方:受付ID、処理段階、競合・保留件数を必要な範囲で記録する。改修前のデータと、キー追加後の再試行が混在する期間も試験する。
DB受付を変更でき、キーを保持できるなら、同一キーで同じ受付を返す限定改修を検証する価値があります。メールを直接送るだけのフォームや、保存先を照会できない仕組みでは、この案をそのまま当てはめず、まず受付の証拠を残せるか調べます。
次の一歩は、実際のPHP環境で「commit後の応答消失」「同時送信」「キー期限切れ」を再現し、受付DBと画面の表示が一致するかを確認することです。PHP実機・HTTP結合・実メール配達・本人による再現と説明・業務効果は、今回の未確認範囲として残ります。
参考資料
公式資料は2026年10月26日確認。仕様、今回のローカル結果、対象PHPへ適用する設計案は別々に扱っています。
- RFC 9110 §9.2.2:Idempotent Methods
- PHP:Transactions and auto-commit
- SQLite:The ON CONFLICT Clause
- Python 3.11:sqlite3
この記事を書いた人✏️@YushiYamamoto
個人事業ITPRODX.comの山本勇志です。PHPサイトの改修・保守、Web・CMS開発の担当経験を基に、既存システムの切り分けや設計判断を発信しています。
古いホームページやPHPサイトの調査・限定改修は、対応できる範囲を確認のうえご相談を承ります。
