冪等性

同じ操作を何度実行しても結果が変わらない性質で、分散システムの信頼性を支える

分散システムAPI

冪等性とは

冪等性 (Idempotency) は、同じ操作を何度実行しても結果が変わらない性質である。ネットワーク障害でリトライが発生する分散システムでは、冪等性がないと二重処理 (二重課金、二重注文) が発生する。

ここでいう「結果」はサーバー側の状態に対する効果を指す。RFC 9110 (2022 年 6 月) は冪等性を「同一の要求を複数回送ったときのサーバーへの意図された効果が、1 回送ったときと同じであること」と定義しており、応答そのものは 1 回目と違ってよい。2 回目の DELETE が 404 を返しても冪等性は崩れていない、というのがこの定義の実務上の含意である。サーバーがアクセスログや変更履歴を要求ごとに積み増すことも、冪等性の違反にはならない。

安全 (safe) との混同も多い。安全とは対象を書き換えないこと、冪等とは何度書き換えても同じ状態に落ち着くことで、DELETE は冪等だが安全ではない。逆に GET は安全かつ冪等である。

HTTP メソッドの冪等性

HTTP メソッドの冪等性を以下にまとめる。

メソッド同じ要求を繰り返したとき対象への変更障害時の扱い
GET何度呼んでも同じ表現が返る変更しない応答が返らなければそのまま再送してよい
PUT全項目を送るため、最終状態は 1 回目と同じ変更するタイムアウトしても同じ本文で送り直せる
DELETE2 回目以降は対象が無く、何も起きない変更する再送の応答が 404 でも、状態としては削除済みで揃っている
POST呼んだ回数だけリソースが増える変更する再送が二重登録になるため、下記の冪等キーで守る
PATCH{"count": 5} のような値の指定なら同じ結果になるが、加算のような差分指定では毎回変わる変更する仕様として保証されないため、自分の API がどちらかを決めて明示する

RFC 9110 が冪等と定めるのは PUT・DELETE と安全なメソッド (GET・HEAD・OPTIONS・TRACE) で、POST と PATCH は含まれない。この線引きは自動再送の可否に直結する。同仕様はプロキシが非冪等な要求を自動再送することを禁じており、クライアントにも、自動再送が失敗したときにさらに自動再送することを避けるよう求めている。非冪等なメソッドを自動で送り直してよいのは、要求の意味が実際には冪等だと設計上分かっているか、1 回目が適用されなかったと確認できる手段がある場合だけである。

POST を冪等にする: 冪等キー

POST を冪等にする: 冪等キーのコード例を示す。

// クライアントが一意の冪等キーを生成
const idempotencyKey = crypto.randomUUID();

fetch('/api/orders', {
  method: 'POST',
  headers: { 'Idempotency-Key': idempotencyKey },
  body: JSON.stringify({ productId: 'P1', quantity: 2 }),
});

DynamoDB の条件付き書き込み

DynamoDB の条件付き書き込みのコード例を示す。

// 冪等な書き込み: 同じ orderId で 2 回実行しても 1 件だけ作成
await db.put({
  TableName: 'orders',
  Item: { orderId, ...orderData },
  ConditionExpression: 'attribute_not_exists(orderId)', // 既に存在したらエラー
});

この書き方の落とし穴は、2 回目の呼び出しが成功ではなく ConditionalCheckFailedException で返る点である。呼び出し側がこの例外を「既に処理済み」と読み替えない限り、リトライのたびにエラーが上がり、冪等にしたつもりの経路が失敗として扱われる。また条件付き書き込みが原子的なのは 1 項目に対してだけなので、注文本体と在庫引き当てのように複数項目をまとめて守りたいなら TransactWriteItems に移す。TransactWriteItems は ClientRequestToken を渡すと呼び出し自体が冪等になるが、トークンの有効期間は最初の要求が完了してから 10 分で、同じトークンのまま内容を変えて送ると IdempotentParameterMismatch が返る。

Lambda Powertools の Idempotency

Lambda Powertools の Idempotency のコード例を示す。

import { makeIdempotent } from '@aws-lambda-powertools/idempotency';
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';

const processOrder = makeIdempotent(async (event) => {
  return await createOrder(event);
}, { persistenceStore: new DynamoDBPersistenceLayer({ tableName: 'idempotency' }) });

永続化先の DynamoDB テーブルは、パーティションキーを id (文字列)、TTL 属性を expiration として用意する前提になっている。記録の保持は既定で 3600 秒で、この窓を過ぎた同じキーは新しい取引として実行される。つまりこの機構が守るのは「一定期間内の重複」であって、永久の一意性ではない。

もう一つの落とし穴が冪等キーの作り方である。既定ではイベント全体のハッシュを冪等キーにするため、タイムスタンプやトレース ID のように毎回変わる項目がペイロードに混ざっていると、同じ注文でも別の要求と見なされて二重に処理される。安定した部分だけを eventKeyJmesPath で指定するのが定石である。1 回目が完了しないうちに同じペイロードで呼ばれた場合は IdempotencyAlreadyInProgressError が投げられるので、呼び出し側で待ち直すか、失敗として返して後で再送させるかを決めておく。

冪等でない操作の危険性

冪等でない操作の危険性を図で示す。

1. クライアントが POST /orders を送信
2. サーバーが注文を作成、レスポンスを返す途中でネットワーク障害
3. クライアントはレスポンスを受け取れず、リトライ
4. サーバーが同じ注文をもう 1 件作成 → 二重注文!

問題の根は、クライアントから見て「要求がサーバーに届かなかった」と「処理は終わったが応答が失われた」が区別できないことにある。区別できないなら再送するしかなく、だからこそ冪等キーは再送のときに作るのではなく、最初の送信前にクライアント側で決めておき、リトライでは同じ値を送り続ける必要がある。

理論と実装の両面から学ぶなら関連書籍が参考になる。

この記事は役に立ちましたか?

関連用語

関連する記事