すべてのトランザクションシステムが答えるべき質問がひとつあります:

同じリクエストが2回届いた場合、データベースには1つのレコードが入るのか、2つ入るのか?

リクエストの重複はさまざまな理由で発生します。ダブルクリックはその中でもっとも目に見えやすい原因に過ぎません:

  • 保存ボタンのダブルクリック
  • タイムアウト後のブラウザによるリトライ
  • ネットワーク切断と再接続
  • リバースプロキシやロードバランサによるリトライ
  • 信号を失った後のモバイルクライアントの再送
  • フロントエンドのバグによるリクエストの2重発行
  • APIコンシューマーによる失敗した呼び出しのリトライ
  • Webhookプロバイダーによるイベントの再送

重複したリクエストを防ぐことはできません。防げるのは重複したレコードだけです。その答えはデータベースが強制するべき等性です。

この記事を通じて、常に次の区別を意識してください:Djangoはバリデーションを行い、PostgreSQLは正しさを保証する。例として請求書モジュールでこのパターンを実装しますが、どのような作成エンドポイントにも適用できます。

問題

ユーザーが請求書を保存します。リクエストが遅く、リトライ(ユーザー、ブラウザ、ネットワークのいずれかによる)が行われると、同一の請求書が2つ存在することになります。レポート、在庫、会計のすべてが狂います。注文、支払い、登録など、データを作成するあらゆるモジュールで同じ障害が起きる可能性があります。

クライアントサイドの防止だけでは不十分な理由

リクエスト送信中に保存ボタンを無効化するのは優れたUXであり、ほとんどの誤ったダブルクリックを防ぎます:

<button :disabled="saving" @click="save">Save</button>

Enter fullscreen mode Exit fullscreen mode

しかし、ネットワークリトライ、リロード、プロキシ、その他のAPIコンシューマーに対しては何の効果もありません。フロントエンドは重複を減らすことはできても、何かを保証することは決してできません。

フロントエンドが有用に行えることは、各操作に識別子を与えることです。操作が開始された時点でべき等性キーを生成し(リクエスト送信時ではなく)、すべての試行で再利用します:

data() {
  return {
    invoice: {},
    idempotencyKey: crypto.randomUUID(), // once per operation
  };
},
methods: {
  async save() {
    await axios.post('/api/invoices/', this.invoice, {
      headers: { 'X-Idempotency-Token': this.idempotencyKey },
    });
  },
  newInvoice() {
    this.invoice = {};
    this.idempotencyKey = crypto.randomUUID(); // reset only here
  },
},

Enter fullscreen mode Exit fullscreen mode

1つの操作、1つのキーです。リクエストごとにキーを生成すると(例: グローバルなAxiosインターセプター内)、すべてのリトライが新しい操作のように見え、サーバーは重複排除できません。

先にチェックする方法が機能しない理由

直感的なバックエンドのアプローチは、挿入前にチェックすることです:

if not Invoice.objects.filter(idempotency_key=token).exists():
    Invoice.objects.create(idempotency_key=token, ...)

Enter fullscreen mode Exit fullscreen mode

並行処理ではこれは失敗します:

Request A arrives
    ↓
exists() → false
                        Request B arrives
                            ↓
                        exists() → false
A inserts row
                        B inserts row   ← duplicate

Enter fullscreen mode Exit fullscreen mode

どちらのリクエストも、どちらかが挿入する前にチェックを通過してしまいます。この「チェックしてアクション」という競合状態は、キャッシュベースの重複排除(cache.get() の後に cache.set())にも同様に当てはまります。チェックと挿入が2つの別々のステップであり、その間に別のリクエストが実行される可能性があるため、アプリケーションレベルのチェックではこれを修正できません。

get_or_create() に関する注意: 多くのDjango開発者がここでこれを使おうとしますが、べき等性の代わりにはなりません。内部的には依然としてルックアップの後に挿入が行われ、ルックアップフィールドがすでにビジネス上のユニーク性を表している場合にのみ役立ちます。べき等性キーは異なります。これはレコードの内容に関係なく、単一のクライアント操作を識別します。

PostgreSQLにユニーク性を保証させる

解決策は、保証をデータベース側に移すことです:

class Invoice(models.Model):
    idempotency_key = models.UUIDField(unique=True, db_index=True)
    # ... your fields

Enter fullscreen mode Exit fullscreen mode

アプリケーションチェックが機能しないのに、これが機能するのはなぜでしょうか? PostgreSQLはユニークチェックと挿入をデータベースエンジン内部で単一のアトミック操作として実行するからです。2つの同時トランザクションが同じユニーク値を挿入することはできません。1つは成功し、もう1つはIntegrityErrorで失敗します。チェックと挿入の間にギャップがないため、競合状態は発生しません。

そこで、IntegrityErrorを失敗として扱うのではなく、作業がすでに完了していることを示すシグナルとして扱います:

from django.db import IntegrityError, transaction

def create(self, request):
    token = request.headers.get("X-Idempotency-Token")
    if not token:
        return Response({"detail": "Idempotency token required."}, status=400)

    serializer = InvoiceSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)

    try:
        with transaction.atomic():
            invoice = serializer.save(idempotency_key=token)
    except IntegrityError:
        invoice = Invoice.objects.get(idempotency_key=token)
        return Response(InvoiceSerializer(invoice).data, status=200)

    return Response(InvoiceSerializer(invoice).data, status=201)

Enter fullscreen mode Exit fullscreen mode

最初のリクエスト:201 Created。以降の再生:200 OK で同じレコードを返します。Djangoがリクエストを検証し、PostgreSQLが正しさを保証しました。

ビジネス制約の追加

1層の保護は良いことですが、2層であればさらに良いです。ほとんどのレコードには、それ自体に自然なユニークルールがあります。これを2番目の制約として強制します:

class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["terminal_id", "number"],
            name="unique_invoice_per_terminal",
        )
    ]

Enter fullscreen mode Exit fullscreen mode

将来のバグがべき等性キーをバイパスした場合でも、PostgreSQLは重複を拒否します。制約はコストがかからず、まだ書いていないバグからも保護してくれます。

再利用可能なアーキテクチャ

あらゆるモジュールで再利用可能な完全なフロー:

Client
    ↓  generate one idempotency key per operation
Django
    ↓  validate the request
PostgreSQL
    ↓  unique constraint enforces exactly-once insert
IntegrityError
    ↓  caught by Django
Return the existing resource (200 OK)

Enter fullscreen mode Exit fullscreen mode

システム内のすべての作成エンドポイントが、この正確な形に従うことができます。

いつこれを使うべきか

べき等性を使うべきなのは、重複が損害を引き起こす場合です:

  • 請求書、注文、発注書
  • 支払いと送金
  • 在庫移動と出荷
  • チケット作成と登録

省略できるのは、自然に繰り返しても安全な操作です:

  • 検索エンドポイント
  • レポートと分析
  • 読み取り専用のAPI

パフォーマンス

ユニークインデックスのチェックは、単一の非常に高速なルックアップであり、マイクロ秒単位で完了します。制約は挿入に意味のあるオーバーヘッドを追加せず、PostgreSQLはキュー、分散ロック、特別なインフラなしに、高い書き込み量でこのパターンを処理します。ほとんどのアプリケーションにとって、パフォーマンスはここでは問題になりません。

重要なポイント

トランザクションシステムは、データの整合性を保つためにタイミング、クライアントの動作、ネットワーク状態に依存すべきではありません。これらは予測不可能だからです。データベースだけが、並行処理下でのユニーク性を保証できるコンポーネントです。操作ごとに1つのべき等性キーを生成し、PostgreSQLのユニーク制約で強制し、IntegrityErrorをキャッチして既存のレコードを返します。その保証を中心にAPIを構築すれば、重複レコードのバグは永遠に消えます。