CRUDアプリで重複レコードを防ぐには、データベースの一意制約、冪等性キー、二重送信を防ぐUI状態など、複数の層を組み合わせる必要があります。

重複レコードとは、アプリが同じものを二度保存してしまうことです。たとえば同じチェックアウトに対する二つの注文、同じ内容のサポートチケットが二件、あるいは同じサインアップフローから作られた二つのアカウントなどです。CRUDアプリでは、個々の行は一見普通に見えますが、データ全体として見ると誤りです。
多くの重複は普通の操作から始まります。ページが遅く感じられて誰かがCreateを二回クリックしてしまう。モバイルではダブルタップは見落としやすい。ボタンがまだ有効に見えて「何も起きていない」ようなら、注意深いユーザーでも再度試します。
その後はさらにややこしい層が絡みます:ネットワークやサーバーです。リクエストがタイムアウトして自動的に再送されることがあります。クライアントライブラリが最初のPOSTが失敗したと判断して繰り返すこともあります。最初のリクエストは成功しているのにレスポンスが失われ、ユーザーが再試行して二重に作られる、ということも起きます。
これを一層だけで解決することはできません。各層は物語の一部分しか見ていないからです。UIは偶発的な二重送信を減らせますが、接続の再試やネットワーク障害を止めることはできません。サーバーは再送を検出できますが、「これは同じcreateだ」と認識する信頼できる方法が必要です。データベースはルールを強制できますが、「同じもの」が何かを定義しておく必要があります。
目標は単純です:同じリクエストが二度来ても作成が安全であること。二回目の試行は何もしない(no-op)、すでに作成済みであることを明確に返す、あるいは制御された競合を返すべきであり、二つ目の行が増えてはいけません。
多くのチームは重複をデータベースの問題だと考えがちです。しかし実際には、同じcreateアクションが複数回トリガーされたときに重複は生まれます。
ユーザーがCreateを押して何も起きないように見えると、再度押します。Enterを押してからすぐボタンをクリックすることもあります。モバイルでは短い間に二回タップが入ったり、タッチとクリックイベントが重なったり、ジェスチャが二重に記録されたりします。
ユーザーが一回だけ送信しても、ネットワークが同じリクエストを繰り返すことがあります。タイムアウトがリトライを引き起こすことがあります。オフラインアプリは「保存」をキューに入れ、再接続時に再送します。HTTPライブラリの中には特定のエラーで自動的にリトライするものがあり、重複行ができるまで気付かないこともあります。
サーバーは意図的に作業を繰り返すことがあります。ジョブキューは失敗したジョブを再試行します。Webhookプロバイダは、エンドポイントが遅いか非2xxを返すと同じイベントを何度も届けることがよくあります。これらのイベントでcreateロジックが起動するなら、重複は起こると考えておくべきです。
同時実行は最も厄介な重複を生みます。二つのタブが同じフォームをミリ秒単位で送信する場合、サーバーが「存在するか?」とチェックしてから挿入する処理を行うと、どちらのリクエストもチェックを通過してしまい、両方が挿入される可能性があります。
クライアント、ネットワーク、サーバーを別々の再送の原因とみなし、すべてに対する防御が必要です。
重複を阻止する信頼できる一箇所が欲しいなら、ルールをデータベースに置いてください。UIやサーバーのチェックは役に立ちますが、リトライや遅延、同時ユーザーの操作下では失敗することがあります。データベースの一意制約は最終的な権威です。
人々がレコードをどう認識するかに合う現実的な一意性ルールを選びましょう。一般的な例:
フルネームのように一意に見えるが実際は違うフィールドには注意してください。
ルールが決まったら、一意制約(または一意インデックス)で強制します。これにより、二つのリクエストが同時に来ても二回目の挿入が拒否されます。
制約が発動したらユーザーにどう見せるかを決めておきます。重複作成が常に誤りなら明確なメッセージで弾きます(「そのメールは既に使われています」)。リトライがよくあるケースでレコードが既に存在するなら、再試行を成功として既存レコードを返す方が良いことが多いです(「あなたの注文は既に作成済みです」)。
「作るか再利用する(create or reuse)」がビジネス上自然なら、upsertが最もきれいなパターンです。例:「メールで顧客を作る」は新しい行を挿入するか既存を返すようにできます。これはビジネス上妥当な場合にのみ使ってください。ほぼ同じキーに対して微妙に異なるペイロードが届く可能性があるなら、どのフィールドを更新してよいか、どれを不変にするかを決めておきます。
一意制約は冪等性キーや適切なUIに取って代わるものではありませんが、他が依存できる堅牢なストッパーを提供します。
冪等性キーは「この注文を一度だけ作る」といったユーザーの意図を表す一意のトークンです。同じリクエストが再度送られた場合(ダブルクリック、ネットワークリトライ、モバイルの再開など)、サーバーはそれを新しい作成ではなく再試行として扱います。
クライアントが最初の試行の成否を判断できない場合、Createエンドポイントを安全にする最も実用的な手段の一つです。
重複が高コストまたは混乱を招くエンドポイント(注文、請求書、支払い、招待、サブスクリプション、メールやWebhookをトリガーするフォームなど)は特に恩恵を受けます。
リトライ時には、サーバーは最初の成功時の結果を返すべきです。作成したレコードIDやステータスコードも同じにします。そのために、(ユーザーまたはアカウント)+エンドポイント+冪等性キーをキーにして小さな冪等性レコードを保存します。結果(レコードID、レスポンス本文)と、「進行中」の状態も保存しておくと、ほぼ同時の二つのリクエストが両方とも新規作成することを防げます。
冪等性レコードは実際に起こりうるリトライをカバーするだけの期間保存してください。一般的な基準は24時間、支払いでは48〜72時間が多いです。TTLを設定して保存を制限し、クライアントが再試行する可能性のある期間に合わせます。
チャット駆動のビルダでAPIを生成する場合でも(例:Koder.ai (koder.ai))、冪等性を明示的に設計してください:クライアント送信のキー(ヘッダやフィールド)を受け付け、「同じキーなら同じ結果」をサーバーで保証します。
冪等性によりCreateリクエストを繰り返し安全にできます。クライアントがタイムアウトで再試行したりユーザーが二回クリックしても、サーバーは二つ目の行を作らず最初と同じ結果を返します。
Idempotency-Keyのようなヘッダが使いやすいが、JSON本文に入れてもよい。重要なのは「チェック+保存」が並行性の下で安全であることです。実務では、冪等性レコードに対して (scope, key) の一意制約を付け、競合が起きたら既存を再利用するという扱いをします。
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
例:顧客が「請求書を作成」してアプリがキー abc123 を送ると、サーバーは請求書 inv_1007 を作ります。電話の電波が切れて再送されたとしても、サーバーは同じ inv_1007 を返し、inv_1008 は作りません。
テストでは「ダブルクリック」だけで満足してはいけません。クライアントでタイムアウトが起きてもサーバー側で処理が完了しているシナリオを再現し、同じキーで再試行したときに正しい動作を確認してください。
サーバー側の防御は重要ですが、多くの重複は人間が同じ操作を繰り返すことから始まります。良いUIは安全な経路を明確にします。
送信と同時にボタンを無効化しましょう。最初のクリックで行い、バリデーション後やリクエスト開始後では遅すぎます。フォームがEnterとボタンなど複数のコントロールで送信できるなら、ボタンだけでなくフォーム全体の状態をロックしてください。
「動いているか?」という質問に答える明確な進行表示を出します。単純な「保存中…」ラベルやスピナーで十分です。レイアウトが変わってボタンが動くと、再度クリックを誘発するのでレイアウトは安定させます。
少ないルールでほとんどの二重送信を防げます:送信ハンドラの開始時に isSubmitting フラグを立て、真の間は新しい送信を無視し(クリックとEnterの両方)、本当に応答が返るまでフラグをクリアしない、という具合です。
遅いレスポンスが多くのアプリの落とし穴です。固定タイマー(たとえば2秒で再度有効にする)で再有効化すると、最初のリクエストがまだ完了していない間に再送されてしまいます。再有効化は試行が完了したときだけ行ってください。
成功後は再送を起こしにくくします。生成されたレコードページや一覧に遷移する、あるいは作成成功の明確な表示で作成済みであることを示すなど、同じ記入済みフォームをそのまま残してボタンを有効にしておかないでください。
しつこい重複バグは、二つのタブ、リフレッシュ、電話の電波切れといった「よくある珍しい」振る舞いから来ます。
まず、一意性のスコープを正しく定義してください。「一意」は滅多に「データベース全体で一意」を意味しません。多くの場合はユーザーごと、ワークスペースごと、テナントごとなどのスコープが入ります。外部システムと同期する場合は、外部ソースごとの一意制約と外部IDの組を使う必要があるかもしれません。安全な方法は、自分が意味する文を一言で書き下すことです(例:「テナントごとに1年単位の請求書番号は1つ」)。それを強制してください。
マルチタブは古典的な落とし穴です。UIの読み込み状態は同一タブでは役立ちますが、タブをまたいだ動作は防げません。ここでサーバー側の防御が必要になります。
Backボタンやリフレッシュは偶発的な再送を引き起こします。成功後にユーザーが確認のためにリフレッシュしたり、戻るを押してまだ編集可能に見えるフォームを再送信することがあります。作成直後はフォームではなく作成後ビューを優先し、サーバーは安全なリプレイを処理するようにしましょう。
モバイルは中断が入りやすい:バックグラウンド化、回線の不安定さ、自動リトライなどです。リクエストは成功しているがアプリがレスポンスを受け取れず、再開時に再送されることがあります。
最も一般的な失敗はUIだけを頼りにすることです。ボタンを無効化しスピナーを出すことは助けになりますが、リフレッシュ、モバイルの不安定さ、複数タブ、クライアントバグをカバーしません。サーバーとデータベースが「このcreateは既に終わっている」と言えるようにする必要があります。
別の罠は、間違ったフィールドを一意にすることです。姓や丸めたタイムスタンプ、自由形式のタイトルのように本当は一意でないものに一意制約を置くと、有効なレコードがブロックされます。代わりに外部プロバイダIDのような実際の識別子や、スコープ付きのルール(ユーザーごと、日ごと、親レコードごと)を使ってください。
冪等性キーも誤って実装しやすい点があります。クライアントが毎回のリトライで新しいキーを生成していると、毎回新しい作成が発生します。最初のクリックからすべてのリトライにかけて同じキーを保持してください。
また、リトライ時に何を返すかにも注意してください。最初のリクエストがレコードを作成していた場合、リトライは同じ結果(少なくとも同じレコードID)を返し、ユーザーが再試行したくなるような曖昧なエラーを返してはいけません。
一意制約が重複を弾いたときは「何かが間違った」とだけ表示しないでください。平易な言葉で説明しましょう:「この請求書番号は既に存在します。元のレコードを保持し、二つ目は作成しませんでした。」
リリース前に、重複作成経路だけを対象に簡単な確認を行ってください。複数の防御層を重ねることで、見落としやクリック、遅いネットワークによって二行できるのを防げます。
次の3点を確認してください:
実用的な感覚チェック:フォームを開き、素早く2回送信してから送信中にリフレッシュして再度試してみてください。二つ作れてしまうなら、実際のユーザーも同じことをしてしまいます。
小さな請求アプリを想像してください。ユーザーが新しい請求書を入力してCreateを押します。ネットワークが遅く、画面がすぐに変わらないので二度タップしてしまう場面です。
UIだけの保護なら、ボタンを無効化してスピナーを出すかもしれません。それでも十分とは言えません。デバイスによってはダブルタップが通ることもあり、タイムアウト後にリトライが来たり、別タブから送信されることもあります。
データベースの一意制約だけなら厳密な重複は止められますが、ユーザー体験が荒くなりがちです。最初のリクエストは成功しており、二回目が制約で弾かれるとユーザーにはエラーが表示されますが実際は既に作成済みという状況になります。
きれいな結果は冪等性+一意制約です:
二回目のタップに対するシンプルなUIメッセージ:「請求書を作成しました — 重複送信は無視して最初のリクエストを採用しました。」
ベースラインが整ったら、次の改善は可視化、クリーンアップ、整合性に関するものです。
作成パス周りに軽量のログを追加して、本当にユーザーがアクションを起こしたのかリトライなのかを判別できるようにします。ログには冪等性キー、関係する一意フィールド、結果(作成されたか既存を返したか、拒否されたか)を含めます。必ずしも重いツールは必要ありません。
既に重複がある場合は、明確なルールと監査証跡を用いてクリーンアップしてください。たとえば古いものを勝者とし、関連テーブル(支払い、行項目)を再接続し、他をマージ済みとしてマークする(削除ではなく)とサポートやレポートが楽になります。
一意性と冪等性のルールを一箇所に書き残してください:何が一意でどのスコープか、冪等性キーはどれくらい保持するか、エラーはどう見えるか、UIはリトライ時にどうするか。これにより新しいエンドポイントが安全性を回避するのを防げます。
CRUD画面を素早くKoder.ai (koder.ai)で作る場合でも、これらの振る舞いをデフォルトテンプレートに組み込む価値があります:スキーマに一意制約、APIに冪等性対応のCreateエンドポイント、UIに明確な読み込み状態を含めることで、速度がデータの混乱を招かないようにできます。
重複レコードとは、同じ実世界の対象が2回保存されている状態です。たとえば、1回のチェックアウトに対して2件の注文が入ったり、同じ内容のサポートチケットが2つ作られるようなケースです。多くの場合、同じ「create」処理が複数回実行されたことが原因で発生します。
ユーザーが1回しかクリックしていなくても、二重作成は起こります。モバイルのダブルタップや、Enterキーで送信した直後にボタンを押すなど、ユーザー側の動作が重なることがあります。さらに、クライアント、ネットワーク、サーバーがタイムアウトやリトライを行うと、同じ処理が再発されることがあります。POSTが常に“一度だけ”を意味するとは限りません。
確実ではありません。ボタンを無効化し「保存中…」を表示することで偶発的な二重送信は減りますが、ネットワークのリトライ、ページ更新、複数タブ、バックグラウンドワーカー、Webhookの再送などは防げません。サーバー側やデータベースの防御も必要です。
一意制約は最後の防衛線で、同じ値が既に存在する場合に二つ目の行を挿入させません。現実のユニークルール(テナント単位やワークスペース単位など)を明確にして、データベースで直接制約を課すのが有効です。
両方必要な場合が多いです。一意制約はフィールドに基づく重複を防ぎ(例:請求書番号)、冪等性キーは特定の作成意図そのものを繰り返しても安全にします。同時に使うことで、安全性とユーザー体験の両方を得られます。
ユーザーの1つの意図(たとえば「請求書を作る」操作)につき1つのキーを生成し、その意図に対する再試行では同じキーを使い続けます。キーはタイムアウトやアプリの再開にも耐えるように保持し、別の作成処理には再利用しないでください。
サーバー側でスコープ(ユーザーやアカウント)、エンドポイント、冪等性キーでキーを記録し、最初に成功した際のレスポンスを保存します。同じキーが再度来たら、新しい行を作らずに保存済みのレスポンス(元のレコードIDを含む)を返します。
『チェック+保存』の流れを並行処理に安全にする必要があります。通常は(スコープ+キー)に対する一意制約を冪等性レコードに設け、競合が起きたら既存の保存済み結果を使うようにします。これでほぼ同時に来た2つのリクエストが両方とも新規作成してしまうことを防げます。
現実的な再試行をカバーするだけの間は保存しておきます。一般的なベースラインは約24時間、支払いなどでは48〜72時間保持するチームもあります。TTLを付けてストレージが無限に増えないようにしましょう。
重複が明らかに同じ意図によるものなら、それを成功した再試行として扱い、元のレコード(同じID)を返すのがベストです。一方、メールアドレスのように本当に一意でなければならない場合は、何が既に存在するのかを明確に伝える競合メッセージを返してください。