🍣

マイクロサービスの冪等性完全ガイド:安全で信頼性の高いAPI設計の基礎

に公開

はじめに

マイクロサービスアーキテクチャにおいて、冪等性(Idempotency) は最も重要な設計原則の一つです。ネットワーク障害、タイムアウト、重複リクエストなど、分散システム特有の問題に対処するために不可欠な概念です。

本記事では、冪等性の基本概念から実装パターン、実際のコード例まで、実践的な知識を体系的に解説します。

冪等性とは何か

基本的な定義

冪等性(Idempotency) とは、同じ操作を何度実行しても、結果が変わらないという性質のことです。

数学的な例

f(x) = f(f(x)) = f(f(f(x))) = ...

例:絶対値関数
|5| = 5
||5|| = |5| = 5
|||5||| = ||5|| = |5| = 5

プログラミングでの例

// 冪等な操作
user.setName("田中太郎");
user.setName("田中太郎"); // 何度実行しても同じ結果
user.setName("田中太郎");

// 冪等でない操作
balance = balance + 1000; // 実行するたびに値が変わる
balance = balance + 1000; // 危険!

なぜマイクロサービスで重要なのか

マイクロサービスでは、以下のような問題が頻繁に発生します:

1. ネットワークタイムアウト

クライアント → API Gateway → Payment Service
     ↑                              ↓
  タイムアウト                    実際は成功
     ↑                              ↓
   再試行 → 重複処理の危険性!

2. 重複リクエスト

ユーザーが「送金」ボタンを連続クリック
→ 複数の送金リクエストが送信
→ 意図しない重複処理

3. システム障害からの復旧

処理途中でサーバーがクラッシュ
→ 再起動後に処理を再実行
→ 重複処理の可能性

HTTP メソッドと冪等性

HTTP メソッドの冪等性分類

メソッド 冪等性 説明
GET ✅ 冪等 データを取得するだけ GET /users/123
PUT ✅ 冪等 リソース全体を置き換え PUT /users/123
DELETE ✅ 冪等 リソースを削除 DELETE /users/123
POST ❌ 非冪等 新しいリソースを作成 POST /users
PATCH ⚠️ 場合による 部分更新(実装次第) PATCH /users/123

PATCHメソッドが冪等性を保証するケースとしないケース

// 1回目の実行
PATCH /api/accounts/456
{
  "status": "active",
  "lastModified": "2024-01-15T10:30:00Z"
}
// 結果: { id: 456, name: "John", status: "active", balance: 1000, lastModified: "2024-01-15T10:30:00Z" }

// 2回目の実行(同じリクエスト)
PATCH /api/accounts/456
{
  "status": "active",
  "lastModified": "2024-01-15T10:30:00Z"
}
// 結果: { id: 456, name: "John", status: "active", balance: 1000, lastModified: "2024-01-15T10:30:00Z" }
// ↑ 同じ結果!
// 相対的な変更は冪等でない
PATCH /api/accounts/456
{
  "balance": "+100"  // 現在の残高に100を加算
}

// 1回目: balance 1000 → 1100
// 2回目: balance 1100 → 1200  ← 結果が変わる!

具体例で理解する

✅ 冪等な操作の例

// PUT: ユーザー情報の更新(冪等)
@PutMapping("/users/{id}")
public ResponseEntity<User> updateUser(@PathVariable String id, @RequestBody User user) {
    // 何度実行しても同じ結果
    user.setId(id);
    User updatedUser = userService.updateUser(user);
    return ResponseEntity.ok(updatedUser);
}

// DELETE: ユーザーの削除(冪等)
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
    userService.deleteUser(id); // 存在しないユーザーを削除しても問題なし
    return ResponseEntity.noContent().build();
}

❌ 非冪等な操作の例

// POST: 新しいユーザー作成(非冪等)
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
    // 実行するたびに新しいユーザーが作成される
    User newUser = userService.createUser(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(newUser);
}

// POST: 残高への入金(非冪等)
@PostMapping("/accounts/{id}/deposit")
public ResponseEntity<Account> deposit(@PathVariable String id, @RequestBody DepositRequest request) {
    // 実行するたびに残高が増える(危険!)
    Account account = accountService.deposit(id, request.getAmount());
    return ResponseEntity.ok(account);
}

冪等性を実現する実装パターン

1. 冪等キー(Idempotency Key)パターン

最も一般的で推奨される方法

@RestController
public class PaymentController {
    
    private final PaymentService paymentService;
    private final IdempotencyService idempotencyService;
    
    @PostMapping("/payments")
    public ResponseEntity<Payment> processPayment(
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @RequestBody PaymentRequest request) {
        
        // 1. 冪等キーの検証
        if (idempotencyKey == null || idempotencyKey.trim().isEmpty()) {
            return ResponseEntity.badRequest()
                .body(new ErrorResponse("Idempotency-Key header is required"));
        }
        
        // 2. 既存の処理結果をチェック
        Optional<IdempotencyRecord> existingRecord = 
            idempotencyService.findByKey(idempotencyKey);
            
        if (existingRecord.isPresent()) {
            IdempotencyRecord record = existingRecord.get();
            
            if (record.getStatus() == IdempotencyStatus.COMPLETED) {
                // 既に完了している場合は、前回の結果を返す
                return ResponseEntity.ok(record.getResult());
            } else if (record.getStatus() == IdempotencyStatus.PROCESSING) {
                // 処理中の場合は409 Conflictを返す
                return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(new ErrorResponse("Request is already being processed"));
            }
        }
        
        // 3. 新しい処理を開始
        try {
            // 処理中状態を記録
            idempotencyService.createRecord(idempotencyKey, IdempotencyStatus.PROCESSING);
            
            // 実際の決済処理
            Payment payment = paymentService.processPayment(request);
            
            // 完了状態と結果を記録
            idempotencyService.updateRecord(idempotencyKey, IdempotencyStatus.COMPLETED, payment);
            
            return ResponseEntity.ok(payment);
            
        } catch (Exception e) {
            // 失敗状態を記録
            idempotencyService.updateRecord(idempotencyKey, IdempotencyStatus.FAILED, null);
            throw e;
        }
    }
}

冪等キー管理サービス

@Service
@Transactional
public class IdempotencyService {
    
    private final IdempotencyRepository repository;
    
    public Optional<IdempotencyRecord> findByKey(String key) {
        return repository.findByIdempotencyKey(key);
    }
    
    public IdempotencyRecord createRecord(String key, IdempotencyStatus status) {
        IdempotencyRecord record = IdempotencyRecord.builder()
            .idempotencyKey(key)
            .status(status)
            .createdAt(LocalDateTime.now())
            .build();
            
        return repository.save(record);
    }
    
    public void updateRecord(String key, IdempotencyStatus status, Object result) {
        IdempotencyRecord record = repository.findByIdempotencyKey(key)
            .orElseThrow(() -> new IllegalStateException("Idempotency record not found"));
            
        record.setStatus(status);
        record.setResult(result);
        record.setUpdatedAt(LocalDateTime.now());
        
        repository.save(record);
    }
}

@Entity
@Table(name = "idempotency_records")
public class IdempotencyRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String idempotencyKey;
    
    @Enumerated(EnumType.STRING)
    private IdempotencyStatus status;
    
    @Column(columnDefinition = "TEXT")
    private String result; // JSON形式で結果を保存
    
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    // getters, setters, builder...
}

public enum IdempotencyStatus {
    PROCESSING,
    COMPLETED,
    FAILED
}

2. 自然キー(Natural Key)パターン

ビジネス的に一意な値を使用する方法

@RestController
public class TransferController {
    
    @PostMapping("/transfers")
    public ResponseEntity<Transfer> createTransfer(@RequestBody TransferRequest request) {
        
        // 送金者ID + 受取人ID + 金額 + 日付 で一意性を保証
        String naturalKey = generateNaturalKey(
            request.getFromUserId(),
            request.getToUserId(), 
            request.getAmount(),
            request.getTransferDate()
        );
        
        // 既存の送金をチェック
        Optional<Transfer> existingTransfer = transferService.findByNaturalKey(naturalKey);
        if (existingTransfer.isPresent()) {
            return ResponseEntity.ok(existingTransfer.get());
        }
        
        // 新しい送金を作成
        Transfer transfer = transferService.createTransfer(request, naturalKey);
        return ResponseEntity.status(HttpStatus.CREATED).body(transfer);
    }
    
    private String generateNaturalKey(String fromUserId, String toUserId, 
                                    BigDecimal amount, LocalDate date) {
        return String.format("%s-%s-%s-%s", 
            fromUserId, toUserId, amount.toString(), date.toString());
    }
}

3. 条件付き更新(Conditional Update)パターン

楽観的ロックやバージョン管理を使用

@Entity
public class Account {
    @Id
    private String accountId;
    
    private BigDecimal balance;
    
    @Version
    private Long version; // 楽観的ロック用
    
    private LocalDateTime lastUpdated;
    
    // getters, setters...
}

@Service
@Transactional
public class AccountService {
    
    private final AccountRepository accountRepository;
    
    // 冪等な残高更新
    public Account updateBalance(String accountId, BigDecimal newBalance, Long expectedVersion) {
        Account account = accountRepository.findById(accountId)
            .orElseThrow(() -> new AccountNotFoundException(accountId));
            
        // バージョンチェックで冪等性を保証
        if (!account.getVersion().equals(expectedVersion)) {
            throw new OptimisticLockException("Account has been modified by another transaction");
        }
        
        account.setBalance(newBalance);
        account.setLastUpdated(LocalDateTime.now());
        
        return accountRepository.save(account); // バージョンが自動的にインクリメント
    }
    
    // 冪等な入金処理
    public Account deposit(String accountId, BigDecimal amount, String transactionId) {
        // トランザクションIDで重複チェック
        if (transactionRepository.existsByTransactionId(transactionId)) {
            // 既に処理済みの場合は現在の残高を返す
            return accountRepository.findById(accountId)
                .orElseThrow(() -> new AccountNotFoundException(accountId));
        }
        
        Account account = accountRepository.findById(accountId)
            .orElseThrow(() -> new AccountNotFoundException(accountId));
            
        // 入金処理
        account.setBalance(account.getBalance().add(amount));
        account.setLastUpdated(LocalDateTime.now());
        
        // トランザクション記録を保存(重複防止)
        Transaction transaction = Transaction.builder()
            .transactionId(transactionId)
            .accountId(accountId)
            .amount(amount)
            .type(TransactionType.DEPOSIT)
            .createdAt(LocalDateTime.now())
            .build();
            
        transactionRepository.save(transaction);
        
        return accountRepository.save(account);
    }
}

クライアント側での冪等性対応

JavaScript での実装

class PaymentApiClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
    }
    
    async processPayment(request) {
        // 冪等キーを生成
        const idempotencyKey = this.generateIdempotencyKey(request);
        
        const headers = {
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey,
            'Request-ID': this.generateUUID()
        };
        
        try {
            const response = await fetch(`${this.baseUrl}/api/payments`, {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(request)
            });
            
            if (response.status === 409) {
                // 処理中の場合は少し待ってから再試行
                await this.delay(1000);
                return this.checkPaymentStatus(idempotencyKey);
            }
            
            return await response.json();
            
        } catch (error) {
            throw error;
        }
    }
    
    generateIdempotencyKey(request) {
        // リクエスト内容に基づいてハッシュを生成
        const content = JSON.stringify({
            customerId: request.customerId,
            amount: request.amount,
            currency: request.currency,
            timestamp: Math.floor(Date.now() / 60000) // 1分単位で丸める
        });
        
        return this.sha256(content);
    }
    
    generateUUID() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
            const r = Math.random() * 16 | 0;
            const v = c == 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }
    
    delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    sha256(data) {
        // SHA-256ハッシュの実装(crypto-jsなどを使用)
        // 簡略化のため、実際の実装では適切なライブラリを使用
        return btoa(data).replace(/[^a-zA-Z0-9]/g, '').substring(0, 32);
    }
}

// 使用例
const paymentClient = new PaymentApiClient('https://api.example.com');

// ユーザーがボタンを連続クリックしても安全
document.getElementById('payButton').addEventListener('click', async () => {
    try {
        const result = await paymentClient.processPayment({
            customerId: 'customer-123',
            amount: 1000,
            currency: 'JPY',
            paymentMethod: 'credit_card'
        });
        
        console.log('Payment successful:', result);
    } catch (error) {
        console.error('Payment failed:', error);
    }
});

テスト戦略

単体テスト

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
    
    @Mock
    private PaymentRepository paymentRepository;
    
    @InjectMocks
    private PaymentService paymentService;
    
    @Test
    void shouldReturnExistingPaymentWhenIdempotencyKeyExists() {
        // Given
        String idempotencyKey = "test-key-123";
        Payment existingPayment = Payment.builder()
            .paymentId("payment-123")
            .idempotencyKey(idempotencyKey)
            .status(PaymentStatus.COMPLETED)
            .amount(new BigDecimal("1000"))
            .build();
            
        when(paymentRepository.findByIdempotencyKey(idempotencyKey))
            .thenReturn(Optional.of(existingPayment));
        
        PaymentRequest request = new PaymentRequest();
        request.setAmount(new BigDecimal("1000"));
        
        // When
        PaymentResult result = paymentService.processPayment(request, idempotencyKey, "req-123");
        
        // Then
        assertThat(result.getPaymentId()).isEqualTo("payment-123");
        assertThat(result.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
    }
    
    @Test
    void shouldThrowExceptionWhenPaymentIsProcessing() {
        // Given
        String idempotencyKey = "test-key-456";
        Payment processingPayment = Payment.builder()
            .paymentId("payment-456")
            .idempotencyKey(idempotencyKey)
            .status(PaymentStatus.PROCESSING)
            .build();
            
        when(paymentRepository.findByIdempotencyKey(idempotencyKey))
            .thenReturn(Optional.of(processingPayment));
        
        PaymentRequest request = new PaymentRequest();
        
        // When & Then
        assertThatThrownBy(() -> 
            paymentService.processPayment(request, idempotencyKey, "req-456")
        ).isInstanceOf(PaymentProcessingException.class)
         .hasMessageContaining("already being processed");
    }
}

ベストプラクティス

1. 冪等キーの設計原則

  • 一意性: 同じ操作には同じキー、異なる操作には異なるキー
  • 予測不可能性: 外部から推測できないランダムな値
  • 適切な有効期限: 古いレコードは定期的にクリーンアップ

2. エラーハンドリング

@ControllerAdvice
public class IdempotencyExceptionHandler {
    
    @ExceptionHandler(DuplicateRequestException.class)
    public ResponseEntity<ApiResponse> handleDuplicateRequest(DuplicateRequestException e) {
        return ResponseEntity.ok(ApiResponse.success(e.getExistingResult()));
    }
    
    @ExceptionHandler(RequestProcessingException.class)
    public ResponseEntity<ApiResponse> handleProcessingRequest(RequestProcessingException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ApiResponse.error("Request is being processed"));
    }
}

3. 監視とメトリクス

@Component
public class IdempotencyMetrics {
    
    private final MeterRegistry meterRegistry;
    private final Counter duplicateRequestCounter;
    private final Timer processingTimeTimer;
    
    public IdempotencyMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.duplicateRequestCounter = Counter.builder("idempotency.duplicate.requests")
            .description("Number of duplicate requests detected")
            .register(meterRegistry);
        this.processingTimeTimer = Timer.builder("idempotency.processing.time")
            .description("Time taken to process idempotent requests")
            .register(meterRegistry);
    }
    
    public void recordDuplicateRequest(String operation) {
        duplicateRequestCounter.increment(Tags.of("operation", operation));
    }
    
    public Timer.Sample startProcessingTimer() {
        return Timer.start(meterRegistry);
    }
}

まとめ

🎯 冪等性の重要性

  1. 信頼性: ネットワーク障害やタイムアウトに対する耐性
  2. 安全性: 重複処理による意図しない副作用を防止
  3. ユーザー体験: 安心してボタンをクリックできるUI

🔧 実装パターンの選択

パターン 適用場面 メリット デメリット
冪等キー 汎用的 柔軟性が高い 実装が複雑
自然キー ビジネスロジック明確 シンプル 適用場面が限定的
条件付き更新 データ更新系 データベース機能活用 競合状態の管理が必要

🚀 実践のポイント

  • 設計段階から冪等性を考慮する
  • 適切なHTTPステータスコードを返す
  • 包括的なテストで冪等性を検証する
  • 監視とメトリクスで運用状況を把握する

冪等性は、マイクロサービスアーキテクチャにおける信頼性の基盤です。適切に実装することで、ユーザーに安全で快適な体験を提供できます。


この記事が、冪等性の理解と実装の助けになれば幸いです。質問や改善提案があれば、コメントでお聞かせください。

Discussion