はじめに ネットワーク不安定や前処理の重複クリックにより、1回の注文操作が複数リクエストとして処理されるケースが発生します。この場合、支払いや注文登録が重複し、不正な課金や重複注文が発生します。支払い、注文、還元など状態を変更する処理では、リクエストの冪等性が必須です。
本稿では「冪等性の定義」から、一意キー、トークン、状態遷移機械の実装方法を解説し、支払いコールバックやメッセージキュー処理の実践例をJavaで提示します。
- 冪等性の定義 リクエストを1回実行した場合と複数回実行した場合で、システムの状態が同一であることを指します。結果として、1回実行した場合と等価です。
検索・削除(ID指定): 既に冪等性を備えています。新規作成・更新・支払い: 実装しない限り冪等性がありません。
冪等性設計は、状態を変更する「書き込み操作」に焦点を当てます。
- 重複リクエストの原因 | 発生源 | 事例 | |---|---| | ユーザ/フロントエンド | 連続クリック、リロードによる再送信 | | ネットワーク/ゲートウェイ | タイムアウト再試行、負荷分散による再送信 | | メッセージキュー | 「最低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;
}
- 支払いコールバックの実装 支払いサービスの複数コールバックを対策します。
支払いトランザクションIDを一意キーとしてDBに保存- 既存の支払い記録がある場合は処理をスキップ
@Transactional
public void handlePaymentCallback(PaymentCallbackDTO callback) {
if (paymentRepository.existsByTransactionId(callback.getTransactionId())) {
return; // 既に処理済み
}
paymentRepository.save(new PaymentRecord(callback));
orderRepository.updateStatusToPaid(callback.getPurchaseId());
}
- メッセージキュー処理の冪等性 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 {
// 有効期限延長の処理
}
}
}
- 実装上の注意点
- 一意キー生成: 注文IDはサーバー側で生成(Snowflakeなど)を推奨
- レスポンス戦略: 重複リクエスト時は最初のレスポンスを返し、HTTP 200を返す
- 有効期限管理: Redisキーは適切な有効期間を設定し、不要なデータを削除
- トランザクション: 冪等チェックと業務処理は同一トランザクションで実行