🍣
マイクロサービスの冪等性完全ガイド:安全で信頼性の高い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);
}
}
まとめ
🎯 冪等性の重要性
- 信頼性: ネットワーク障害やタイムアウトに対する耐性
- 安全性: 重複処理による意図しない副作用を防止
- ユーザー体験: 安心してボタンをクリックできるUI
🔧 実装パターンの選択
パターン | 適用場面 | メリット | デメリット |
---|---|---|---|
冪等キー | 汎用的 | 柔軟性が高い | 実装が複雑 |
自然キー | ビジネスロジック明確 | シンプル | 適用場面が限定的 |
条件付き更新 | データ更新系 | データベース機能活用 | 競合状態の管理が必要 |
🚀 実践のポイント
- 設計段階から冪等性を考慮する
- 適切なHTTPステータスコードを返す
- 包括的なテストで冪等性を検証する
- 監視とメトリクスで運用状況を把握する
冪等性は、マイクロサービスアーキテクチャにおける信頼性の基盤です。適切に実装することで、ユーザーに安全で快適な体験を提供できます。
この記事が、冪等性の理解と実装の助けになれば幸いです。質問や改善提案があれば、コメントでお聞かせください。
Discussion