支払い・注文処理におけるリクエスト重複対策: 実践的な実装ガイド

はじめに ネットワーク不安定や前処理の重複クリックにより、1回の注文操作が複数リクエストとして処理されるケースが発生します。この場合、支払いや注文登録が重複し、不正な課金や重複注文が発生します。支払い、注文、還元など状態を変更する処理では、リクエストの冪等性が必須です。

本稿では「冪等性の定義」から、一意キー、トークン、状態遷移機械の実装方法を解説し、支払いコールバックやメッセージキュー処理の実践例をJavaで提示します。

  1. 冪等性の定義 リクエストを1回実行した場合と複数回実行した場合で、システムの状態が同一であることを指します。結果として、1回実行した場合と等価です。
  • 検索・削除(ID指定): 既に冪等性を備えています。
  • 新規作成・更新・支払い: 実装しない限り冪等性がありません。

冪等性設計は、状態を変更する「書き込み操作」に焦点を当てます。

  1. 重複リクエストの原因 | 発生源 | 事例 | |---|---| | ユーザ/フロントエンド | 連続クリック、リロードによる再送信 | | ネットワーク/ゲートウェイ | タイムアウト再試行、負荷分散による再送信 | | メッセージキュー | 「最低1回」のセマンティクスによる重複配信 | | スケジュールタスク | 重複実行、複数インスタンス同時実行 |

再試行が発生する可能性がある限り、ビジネス層で冪等性を保証する必要があります。

  1. 実装手法の比較 3.1 一意キー + DB制約(最も一般的) 業務で生成した一意リクエストID(注文ID、支払いトランザクションIDなど)をDBのユニークインデックスとして使用します。初回登録後に同IDで再度登録するとエラーになり、重複リクエストと判断します。
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY,
    purchase_id VARCHAR(64) NOT NULL UNIQUE COMMENT '一意ID',
    user_id BIGINT NOT NULL,
    amount DECIMAL(10,2),
    current_status VARCHAR(20),
    created_at DATETIME
);
public PurchaseOrder createOrder(CreateOrderDTO dto) {
    String purchaseId = dto.getPurchaseId();
    try {
        return orderRepository.save(PurchaseOrder.builder()
            .purchaseId(purchaseId)
            .userId(dto.getUserId())
            .build());
    } catch (DataIntegrityViolationException e) {
        return orderRepository.findByPurchaseId(purchaseId);
    }
}

3.2 一時トークン(重複送信防止) リクエスト送信前に一時トークンを取得し、送信時に付与します。サーバーはトークンを検証し、使用後は削除します。

// 1. トークン取得(ページ表示時)
String nonce = RandomStringUtils.randomAlphanumeric(32);
redisTemplate.opsForValue().set("idempotent:order:" + nonce, "1", 5, TimeUnit.MINUTES);

// 2. 送信処理
public PurchaseOrder submitOrder(String nonce, CreateOrderDTO dto) {
    String key = "idempotent:order:" + nonce;
    if (!redisTemplate.delete(key)) {
        throw new IllegalStateException("重複送信不可");
    }
    return processOrder(dto);
}

3.3 状態遷移機械(状態管理) 注文状態(未支払い→支払い済み)を明確に定義し、状態遷移の条件をチェックします。

UPDATE purchase_orders 
SET current_status = 'paid', payment_time = NOW()
WHERE purchase_id = ? AND current_status = 'pending';
int updated = orderRepository.updateStatusToPaid(dto.getPurchaseId());
if (updated == 0) {
    PurchaseOrder existing = orderRepository.findByPurchaseId(dto.getPurchaseId());
    return existing.getStatus().equals("paid") ? existing : null;
}
  1. 支払いコールバックの実装 支払いサービスの複数コールバックを対策します。
  • 支払いトランザクションIDを一意キーとしてDBに保存
  • 既存の支払い記録がある場合は処理をスキップ
@Transactional
public void handlePaymentCallback(PaymentCallbackDTO callback) {
    if (paymentRepository.existsByTransactionId(callback.getTransactionId())) {
        return; // 既に処理済み
    }
    paymentRepository.save(new PaymentRecord(callback));
    orderRepository.updateStatusToPaid(callback.getPurchaseId());
}
  1. メッセージキュー処理の冪等性 MQの「最低1回」セマンティクスに対応します。
public void processOrderMessage(Message message) {
    String key = "processed:" + message.getPurchaseId();
    if (redisTemplate.opsForValue().setIfAbsent(key, "1", 24, TimeUnit.HOURS)) {
        try {
            executeOrderProcessing(message);
        } finally {
            // 有効期限延長の処理
        }
    }
}
  1. 実装上の注意点
  • 一意キー生成: 注文IDはサーバー側で生成(Snowflakeなど)を推奨
  • レスポンス戦略: 重複リクエスト時は最初のレスポンスを返し、HTTP 200を返す
  • 有効期限管理: Redisキーは適切な有効期間を設定し、不要なデータを削除
  • トランザクション: 冪等チェックと業務処理は同一トランザクションで実行

タグ: Java distributed-systems payment-api message-queue state-machine

7月20日 00:02 投稿