はじめに
AWS Lambda を使った処理では、同じイベントや同じリクエストが複数回処理されることがあります。
最初は「Lambda が2回動くことなんてそんなにあるのか?」と思っていたのですが、調べていくと、Lambda や周辺サービスを使う以上、重複実行は普通に考慮しておいた方がよさそうだと感じました。
たとえば、以下のようなケースです。
- API クライアントがタイムアウトして、同じリクエストを再送する
- EventBridge、S3イベント通知、SNS などの非同期イベントで Lambda が再実行される
- SQS、Kinesis、DynamoDB Streams などから同じメッセージやレコードが再処理される
- Lambda の処理中にタイムアウトし、呼び出し元やイベントソース側で再試行される
- 処理自体は成功したが、その後のエラーによって Lambda 全体としては失敗扱いになる
このとき、Lambda の処理が冪等になっていないと、次のような問題が起きます。
- 注文が二重登録される
- 決済が二重実行される
- 在庫が二重に減る
- ポイントが重複付与される
- メールや通知が複数回送信される
- 外部APIが重複して呼ばれる
- DBの状態が不整合になる
この記事では、Lambda の冪等性について学んだ内容を、設計時に考える順番に沿って整理します。
冪等性とは
冪等性とは、同じ操作を1回実行しても、複数回実行しても、結果が変わらない性質のことです。
たとえば、以下は冪等です。
ユーザーID: 123 のステータスを「有効」に更新する
これは何度実行しても、最終的な状態は「有効」です。
一方で、以下は冪等ではありません。
ユーザーID: 123 のポイントを 100 加算する
これは2回実行されると、ポイントが200加算されます。
Lambda における冪等性は、単に「同じイベントを無視する」ことではなく、
同じ業務リクエストが再実行されても、副作用が重複しないようにする設計と考えると理解しやすいです。
なぜ Lambda で冪等性が必要なのか
Lambda はサーバーレスで便利ですが、分散システムの性質上、必ず1回だけ実行されるという前提では考えない方がよさそうです。
同じ Lambda でも、呼び出し元によって重複実行が起きる理由が少し違います。
Lambda の呼び出し方式
Lambda の呼び出し方式は、大きく分けると以下のようになります。
| 呼び出し方式 | 意味 | 主な例 |
|---|---|---|
| 同期呼び出し | 呼び出し元が Lambda の処理完了を待つ | API Gateway、ALB、SDK の RequestResponse
|
| 非同期呼び出し | 呼び出し元はイベントを渡したらすぐ戻る | EventBridge、S3イベント通知、SNS、SDK の InvocationType=Event
|
| ポーリング型 | Lambda のイベントソースマッピングがイベントソースから取得して処理する | SQS、Kinesis、DynamoDB Streams |
API Gateway からの呼び出しは基本的に同期呼び出し
API Gateway から通常の Lambda プロキシ統合で Lambda を呼び出す場合、基本的には同期呼び出しです。
つまり、クライアントは Lambda の処理結果を待ちます。
そのため、API Gateway 経由の Lambda は、EventBridge や S3イベント通知のような「Lambda サービス側の非同期リトライ」とは少し性質が違います。
ただし、同期呼び出しだから冪等性を考えなくてよい、というわけではありません。
たとえば、以下のようなケースがあります。
この場合、API Gateway や Lambda が自動で非同期リトライしたわけではありません。
それでも、クライアントの再送によって同じ業務リクエストが複数回来るため、冪等性を考える必要があります。
EventBridge / S3 / SNS などは非同期呼び出し
EventBridge、S3イベント通知、SNS などから Lambda を起動する場合は、非同期呼び出しとして扱われます。
非同期呼び出しでは、呼び出し元は Lambda の処理結果を待ちません。
この方式では、Lambda サービス側でイベントがキューイングされ、関数エラーが発生した場合には再試行されることがあります。
そのため、同じイベントが複数回処理される可能性があります。
SQS / Kinesis / DynamoDB Streams はポーリング型
SQS、Kinesis、DynamoDB Streams は、厳密には Lambda の非同期呼び出しとは少し違います。
これらは、Lambda のイベントソースマッピングがイベントソースをポーリングし、取得したメッセージやレコードを Lambda に渡します。
利用者目線では「キューやストリームに積んで後で処理する」ため非同期処理に見えますが、Lambda の呼び出し分類としてはポーリング型と考えると整理しやすいです。
SQS や Streams は、同じメッセージやレコードが再処理される可能性があります。
そのため、ポーリング型でも Lambda 側の処理は冪等にしておく必要があります。
処理成功後にエラーになるケースが厄介
個人的に一番注意が必要だと思ったのは、業務処理は成功したが、Lambda としては失敗扱いになるケースです。
たとえば、以下のような流れです。
このようなケースでは、イベント配信側だけでは防ぎきれません。
最終的に副作用を起こす Lambda 側で、冪等性を考慮する必要があります。
冪等性を考えるべきユースケース
すべての Lambda に重厚な冪等性制御が必要なわけではありません。
特に考えるべきなのは、副作用がある処理です。
| ユースケース | 冪等性の重要度 | 理由 |
|---|---|---|
| 注文登録 | 高 | 二重注文になる可能性がある |
| 決済処理 | 高 | 二重決済は重大障害になりやすい |
| 在庫引当 | 高 | 在庫数が不正になる |
| ポイント付与 | 高 | 重複付与される |
| メール送信 | 中〜高 | 同じ通知が複数回送られる |
| 外部API呼び出し | 中〜高 | 外部側で二重処理される可能性 |
| DB更新 | 中〜高 | 更新内容によっては不整合になる |
| ログ出力 | 低 | 重複しても業務影響が小さい場合が多い |
| 読み取り専用API | 低 | 副作用がないため影響が小さい |
目安としては、次の問いに「はい」と答えるなら、冪等性を検討した方がよいと思います。
同じイベントが2回処理されたら困るか?
困るなら、冪等性の設計対象です。
冪等性を考慮すべきか、どの方式にするか
冪等性を考えるとき、いきなり「DynamoDB を使うか」「Powertools を使うか」から入ると少し混乱します。
まずは、次の順番で考えると整理しやすいです。
- 副作用がある処理か
- 同じリクエストやイベントが複数回来たら困るか
- リクエスト内に一意な業務キーがあるか
- 処理が単一のDBトランザクションに収まるか
- 外部APIや通知など、DB以外の副作用があるか
- 複数ステップの途中失敗をどう扱うか
- 共通部品として冪等性管理を持つべきか
判断フローにすると以下のようになります。
実務的には、ざっくり次のように考えるとよさそうです。
| 状況 | 優先して検討する方式 |
|---|---|
| 読み取り専用、または副作用がほぼない | 冪等性管理なしでもよい |
| 重複しても業務影響が小さい | ログ・監視で検知する程度でもよい |
| 注文番号など一意な業務キーがある | 業務キー + DB一意制約 |
| 同じ業務キーで同じ内容の再送を正常扱いしたい | 業務キー + request_hash + 既存結果返却 |
| 登録前に業務IDがない | Idempotency-Key方式 |
| 単一DB内で完結する | DBトランザクション + 一意制約 |
| 外部API・通知・複数DBが絡む | 冪等性管理テーブル、Outbox、Saga、補償処理 |
| 複数ステップで途中再開したい | ステップ単位の状態管理 |
| Lambda全体で共通化したい | Powertools Idempotency Utility |
個人的には、まずは 業務キーとDB制約で守れないか を考え、それだけでは足りない場合に Idempotency-Key や DynamoDB による冪等性管理を検討するのが自然だと思いました。
Idempotency-Key や Powertools は便利ですが、すべての処理に機械的に適用するものではなさそうです。
冪等性設計の基本方針
Lambda の冪等性は、基本的には以下の流れで考えます。
重要なのは、Lambda のリクエストIDや X-Ray のトレースIDを冪等性キーにしないことです。
Lambda の aws_request_id や X-Ray のトレースIDは、再実行のたびに変わる可能性があります。
冪等性キーは「Lambda の実行単位」ではなく、業務的に同じ処理を表すIDである必要があります。
冪等性キーの設計
冪等性キーは、システム全体の成否を左右します。
ただし、冪等性キーは必ずしも Idempotency-Key という専用ヘッダーである必要はありません。
重要なのは、同じ業務リクエストを安定して識別できることです。
API の場合
API では、クライアントまたは呼び出し元が冪等性キーを発行する方式があります。
POST /orders
Idempotency-Key: 8f4b7c1e-xxxx-xxxx
X-Tenant-Id: tenant-a
Content-Type: application/json
ボディ例です。
{
"customer_id": "cust_001",
"product_id": "prod_001",
"quantity": 1
}
この場合、冪等性キーは以下のように構成できます。
tenant_id + operation_name + idempotency_key
例です。
tenant-a#create_order#8f4b7c1e-xxxx-xxxx
マルチテナントの場合、tenant_id を含めないと、別テナントのリクエストとキーが衝突する可能性があります。
業務キーがある場合
注文登録のように、リクエスト内に注文番号や受付番号などの業務的に一意なキーが含まれている場合があります。
たとえば、以下のようなリクエストです。
{
"tenant_id": "tenant-a",
"order_no": "ORD-20260628-0001",
"customer_id": "C001",
"amount": 10000
}
この order_no が業務的に一意であれば、別途 Idempotency-Key を用意せず、以下をキーとして扱うことができます。
tenant_id + order_no
これは冪等性を考えていないわけではありません。
Idempotency-Key ヘッダーを使っていないだけで、注文番号などの業務キーを冪等性キーとして使っていると考えるとわかりやすいです。
バッチ / SQS の場合
SQS やバッチ処理では、呼び出し元が業務IDをイベントに含めるのが望ましいです。
{
"tenant_id": "tenant-a",
"job_id": "job_20260628_001",
"order_id": "order_001",
"operation": "create_order"
}
冪等性キーの例です。
tenant-a#create_order#order_001
または、ジョブ単位も含めるなら以下のようにします。
tenant-a#job_20260628_001#order_001
よくないキーの例
| キー | 問題 |
|---|---|
Lambda の aws_request_id
|
再実行ごとに変わる |
| X-Ray の trace id | 業務リクエストの同一性を表さない |
| タイムスタンプ | 毎回変わる |
| リクエストボディ全体の単純なハッシュ |
requested_at など可変項目で別キーになりやすい |
| customer_id だけ | 粒度が粗すぎて別操作まで同一扱いになる |
| tenant_id だけ | 粒度が粗すぎる |
実装パターン
パターン1: 業務キーで重複登録を防ぐ
注文登録のように、リクエストの中に注文を一意に識別できる業務キーが含まれている場合があります。
たとえば、外部システムで採番された注文番号や受付番号があるケースです。
{
"tenant_id": "tenant-a",
"order_no": "ORD-20260628-0001",
"customer_id": "C001",
"amount": 10000
}
この order_no が業務的に一意であれば、別途 Idempotency-Key を用意せず、
tenant_id + order_no
を使って重複登録を防ぐ設計ができます。
この場合、2回目以降の同じ注文登録リクエストに対しては、以下のように返すこともあります。
{
"message": "すでに登録されている注文です"
}
ただし、これは単なる「事前チェック」ではなく、業務キーを使った冪等性制御と考える方がよいです。
「登録前に検索するだけ」は危険
よくある実装として、登録前に既存データを検索する方法があります。
1. SELECT で order_no が存在するか確認する
2. 存在しなければ INSERT する
3. 存在すれば「すでに登録済み」と返す
一見問題なさそうですが、これだけでは同時実行に弱いです。
同じ注文登録リクエストがほぼ同時に2回実行された場合、以下のようなことが起きます。
そのため、アプリケーション側の事前チェックだけでなく、DB側の一意制約で最終的に守る必要があります。
CREATE UNIQUE INDEX uq_orders_tenant_order_no
ON orders (tenant_id, order_no);
実装としては、以下のように考えると安全です。
「すでに登録済み」と返すか、前回と同じ結果を返すか
同じ業務キーの注文がすでに存在していた場合に、
すでに登録されている注文です
と返すべきか、前回と同じレスポンスを返すべきかは、APIの考え方によって変わります。
重複登録を業務エラーとして扱う場合
同じ注文番号での再登録が明確に業務エラーである場合は、409 Conflict を返すのが自然です。
HTTP/1.1 409 Conflict
{
"message": "すでに登録されている注文です"
}
この設計では、同じ注文番号で新規登録しようとしたことを、明確な競合として扱います。
リトライを正常系として扱いたい場合
一方で、APIクライアントがタイムアウトして同じリクエストを再送するケースもあります。
この場合、2回目のリクエストに対して 409 Conflict を返すと、クライアント側から見ると少し扱いづらくなります。
「1回目が成功した結果として登録済みなのか」
「本当にエラーとして扱うべき重複なのか」
を判断しづらいためです。
そのため、同じ業務キーかつ同じリクエスト内容であれば、既存の注文情報を返す設計もあります。
HTTP/1.1 200 OK
{
"message": "すでに登録済みの注文です",
"order": {
"order_no": "ORD-20260628-0001",
"status": "created"
}
}
この設計にすると、クライアントは安全にリトライできます。
同じキーで異なる内容が来た場合
注意すべきなのは、同じ order_no で異なる内容のリクエストが来るケースです。
1回目のリクエストです。
{
"tenant_id": "tenant-a",
"order_no": "ORD-20260628-0001",
"customer_id": "C001",
"amount": 10000
}
2回目のリクエストです。
{
"tenant_id": "tenant-a",
"order_no": "ORD-20260628-0001",
"customer_id": "C001",
"amount": 99999
}
この場合、単純に「登録済み」として既存データを返してしまうと危険です。
同じ注文番号なのに内容が異なるため、リクエストの不整合として扱うべきです。
そのため、登録時にリクエスト内容のハッシュを保存しておくと判定しやすくなります。
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
order_no VARCHAR(64) NOT NULL,
customer_id VARCHAR(64) NOT NULL,
amount INTEGER NOT NULL,
request_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (tenant_id, order_no)
);
処理イメージは以下です。
疑似コードにすると以下のようになります。
def create_order(request):
request_hash = calculate_hash({
"tenant_id": request["tenant_id"],
"order_no": request["order_no"],
"customer_id": request["customer_id"],
"amount": request["amount"],
})
try:
order = insert_order(
tenant_id=request["tenant_id"],
order_no=request["order_no"],
customer_id=request["customer_id"],
amount=request["amount"],
request_hash=request_hash,
)
return {
"statusCode": 201,
"body": {
"message": "注文を登録しました",
"order": order,
},
}
except UniqueConstraintViolation:
existing_order = find_order(
tenant_id=request["tenant_id"],
order_no=request["order_no"],
)
if existing_order.request_hash == request_hash:
return {
"statusCode": 200,
"body": {
"message": "すでに登録済みの注文です",
"order": existing_order,
},
}
return {
"statusCode": 409,
"body": {
"message": "同じ注文番号で異なる内容の注文がすでに存在します",
},
}
パターン2: DBの一意制約で守る
業務キーによる重複防止と近いですが、より一般的には、DB 側に一意制約を持たせる方法があります。
たとえば注文登録なら、order_id や order_no を一意にします。
CREATE TABLE orders (
order_id VARCHAR(64) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
customer_id VARCHAR(64) NOT NULL,
product_id VARCHAR(64) NOT NULL,
quantity INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL
);
同じ order_id の登録が再実行された場合、2回目は主キー制約で失敗します。
ただし、この方法だけだと以下の課題があります。
- 2回目のリクエストに前回と同じレスポンスを返しにくい
- 処理中状態を表現しにくい
- DB書き込み以外の副作用を防げない
- 複数処理をまたぐ場合に制御しづらい
単純な登録処理なら有効ですが、外部API呼び出しや複数テーブル更新が絡む場合は、専用の冪等性管理テーブルを使う方が扱いやすいです。
パターン3: DynamoDB に冪等性レコードを保存する
Lambda の冪等性では、DynamoDB を使って処理状態を管理するパターンがよく使われます。
テーブル例です。
| 属性 | 内容 |
|---|---|
| idempotency_key | 冪等性キー |
| status |
IN_PROGRESS / COMPLETED / FAILED
|
| response_data | 前回のレスポンス |
| payload_hash | リクエスト内容の検証用ハッシュ |
| expiration | TTL |
| in_progress_expiration | 処理中ロックの期限 |
処理の流れです。
DynamoDB の条件付き書き込みを使うことで、同じキーに対する同時実行を防ぎやすくなります。
パターン4: Powertools for AWS Lambda の Idempotency Utility を使う
Python で Lambda を実装している場合は、Powertools for AWS Lambda の Idempotency Utility を使うのが実装しやすいです。
Powertools の Idempotency Utility を使うと、以下のような処理を共通化できます。
- 冪等性キーの抽出
- 処理中状態の管理
- 完了済みレスポンスの再利用
- TTL の管理
- ペイロード検証
- DynamoDB などの永続化レイヤーとの連携
SAM テンプレート例
Resources:
IdempotencyTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: IdempotencyTable
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expiration
Enabled: true
Lambda 実装例
import os
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.idempotency import (
DynamoDBPersistenceLayer,
IdempotencyConfig,
idempotent,
)
logger = Logger()
persistence_layer = DynamoDBPersistenceLayer(
table_name=os.environ["IDEMPOTENCY_TABLE_NAME"]
)
config = IdempotencyConfig(
event_key_jmespath="headers.Idempotency-Key",
payload_validation_jmespath="body",
expires_after_seconds=60 * 60 * 24,
)
@idempotent(
persistence_store=persistence_layer,
config=config,
)
@logger.inject_lambda_context
def lambda_handler(event, context):
body = event["body"]
# ここに副作用のある処理を書く
# 例: 注文登録、決済、在庫引当、外部API呼び出しなど
result = {
"statusCode": 201,
"body": "created"
}
return result
ポイントは、event_key_jmespath で冪等性キーに使う項目を明示することです。
API Gateway 経由なら、たとえば以下のように指定できます。
event_key_jmespath="headers.Idempotency-Key"
マルチテナントであれば、キーに tenant_id も含めた方が安全です。
ヘッダーの値だけでなく、テナントID・操作名・業務IDを組み合わせてキーを作る設計にすると、衝突や誤判定を避けやすくなります。
パターン5: SQS FIFO の重複排除を使う
SQS FIFO には MessageDeduplicationId による重複排除があります。
ただし、これはキュー投入時の重複排除であり、Lambda の処理全体の冪等性を保証するものではありません。
たとえば、以下のようなケースは防げません。
そのため、SQS FIFO を使っていても、最終的な副作用を起こす Lambda 側でも冪等性を持たせる必要があります。
パターン6: Lambda Durable Functions の execution name を使う
Lambda Durable Functions では、execution name を冪等性キーとして扱う設計があります。
同じ execution name で実行を開始することで、重複実行を防ぎ、安全なリトライにつなげることができます。
ただし、イベントソースマッピング経由の呼び出しでは、Durable Functions の冪等性をそのまま使えないケースがあります。
そのため、SQS などのイベントソースマッピングで冪等性を確保したい場合は、以下のような設計を検討します。
- 関数コード側で Powertools などを使う
- 通常の Lambda をディスパッチャとして使う
- ディスパッチャから execution name を指定して Durable Function を呼び出す
業務キー方式と Idempotency-Key 方式の使い分け
業務キー方式と Idempotency-Key 方式は、どちらかが常に正しいというものではありません。
リクエスト時点で、業務的に一意なキーがあるかどうかで使い分けます。
| パターン | キー | 向いているケース |
|---|---|---|
| 業務キー方式 | tenant_id + order_no |
注文番号、受付番号、外部システムIDなどがリクエストに含まれる |
| Idempotency-Key 方式 | tenant_id + operation + Idempotency-Key |
登録前に業務IDが存在しない、サーバー側でIDを採番する |
| 冪等性管理テーブル方式 |
tenant_id + operation + business_id など |
DB更新、外部API、通知など複数の副作用がある |
| Powertools方式 | 設定した JMESPath によるキー | DynamoDBなどで冪等性を共通化したい |
たとえば、注文番号がクライアント側または外部システム側で決まっているなら、業務キー方式で十分な場合があります。
一方で、注文IDをサーバー側で採番する場合は、リクエスト時点で一意に識別できる業務キーがありません。
その場合は、クライアントから Idempotency-Key を受け取り、同じリクエストの再送を識別する方が自然です。
複数ステップ処理における冪等性の注意点
冪等性を考えるときに注意したいのが、1つの Lambda の中で複数の処理を行うケースです。
たとえば、以下のような処理があるとします。
① 商品テーブルへの商品登録処理
② 商品テーブルへの商品金額更新処理
このとき、①は成功したが②で失敗した場合、冪等性キーの扱いを間違えると問題が起きます。
たとえば、①が成功した時点で冪等性レコードを COMPLETED にしてしまうと、再実行時に以下のように判断されてしまいます。
この冪等性キーはすでに処理済み
その結果、②の金額更新処理が再実行されず、商品は登録されているが金額は更新されていない、という中途半端な状態が残る可能性があります。
つまり、冪等性キーは「一度でも処理を開始したら二度と実行しない」ためのものではありません。
大事なのは、処理全体が本当に成功した場合にだけ、処理済みとして扱うことです。
悪い例
この場合、冪等性管理上は完了扱いになっているため、同じ冪等性キーで再実行されても、②が実行されない可能性があります。
良い例
途中で失敗した場合は、COMPLETED にしてはいけません。
失敗時は、以下のいずれかの設計にします。
- FAILED として記録する
- IN_PROGRESS のまま期限切れにして、再実行可能にする
- どこまで成功したかをステップ単位で記録する
対応パターン
複数ステップ処理では、以下のような対応パターンがあります。
| パターン | 内容 | 向いているケース |
|---|---|---|
| DBトランザクションにする | ①②を1つのトランザクションで実行する | 同一DB内の処理 |
| 各処理を個別に冪等化する | ①②それぞれを再実行しても壊れないようにする | 一部成功後に再実行したい処理 |
| ステップ単位で状態管理する | どこまで成功したかを管理する | 複数DB、外部API、長い処理 |
| 処理自体を再設計する | ①②を1つの業務処理にまとめる | 業務的に一体の処理 |
パターン1: DBトランザクションにする
①商品登録と②金額更新が同じDBに対する処理なら、まずはDBトランザクションでまとめられないかを検討します。
BEGIN;
INSERT INTO products (
tenant_id,
product_code,
product_name
) VALUES (
:tenant_id,
:product_code,
:product_name
);
UPDATE products
SET price = :price
WHERE tenant_id = :tenant_id
AND product_code = :product_code;
COMMIT;
②が失敗した場合は、①もロールバックします。
この設計なら、再実行時には最初から安全にやり直せます。
この場合、冪等性レコードは以下のように扱います。
ポイントは、COMPLETED にするのは、DBトランザクションが正常にコミットされた後にすることです。
パターン2: 各処理を個別に冪等化する
DBトランザクションでまとめられない場合や、処理が別サービス・別DB・外部APIにまたがる場合は、各処理を個別に冪等にします。
たとえば、1回目の実行で以下の状態になったとします。
1回目:
① 商品登録 成功
② 金額更新 失敗
この場合、2回目の再実行では以下のように動けるようにします。
つまり、①も②も再実行して問題ない処理にしておく、ということです。
商品登録であれば、tenant_id + product_code などに一意制約を付けます。
CREATE UNIQUE INDEX uq_products_tenant_product_code
ON products (tenant_id, product_code);
再実行時にすでに商品が存在する場合は、以下のように扱います。
同じ内容なら登録済みとして扱う
異なる内容なら 409 Conflict
金額更新については、差分更新ではなく、最終状態を指定する更新にすると冪等にしやすいです。
冪等にしにくい例です。
現在価格に 100 円加算する
これは2回実行すると、200円加算されてしまいます。
冪等にしやすい例です。
価格を 1000 円に更新する
これは何回実行しても、最終的な価格は1000円です。
UPDATE products
SET price = :price
WHERE tenant_id = :tenant_id
AND product_code = :product_code;
このように、可能であれば「差分」ではなく「最終状態」を指定する処理に寄せると、再実行に強くなります。
パターン3: ステップ単位で状態管理する
複数ステップの途中まで成功した状態を明示的に扱いたい場合は、ステップ単位で状態を管理します。
たとえば、冪等性管理テーブルに以下のような情報を持たせます。
| idempotency_key | status | product_registered | price_updated |
|---|---|---|---|
| xxx | IN_PROGRESS | true | false |
または、現在の進行ステップを持たせてもよいです。
| idempotency_key | status | current_step |
|---|---|---|
| xxx | IN_PROGRESS | PRODUCT_REGISTERED |
処理の流れは以下です。
この設計なら、①成功・②失敗の後に再実行された場合でも、①は完了済みとして扱い、②だけ再実行できます。
疑似コードにすると以下のようになります。
def handler(event, context):
key = make_idempotency_key(event)
record = get_or_create_idempotency_record(key)
if record.status == "COMPLETED":
return record.response
if not record.product_registered:
register_product(event)
mark_step_done(key, "product_registered")
if not record.price_updated:
update_price(event)
mark_step_done(key, "price_updated")
response = {
"message": "商品登録と金額更新が完了しました"
}
mark_completed(key, response)
return response
この方式は、複数DB、外部API、長時間処理、途中再開が必要な処理に向いています。
一方で、状態管理が複雑になるため、単純な処理に対して過剰に使う必要はありません。
パターン4: 処理自体を再設計する
今回の例では、商品登録と金額更新が別々の処理に見えます。
しかし、業務的には以下の1つの処理として扱えるかもしれません。
商品を価格付きで登録する
もし商品テーブルに価格カラムがあるなら、最初から価格込みで INSERT する方が自然です。
INSERT INTO products (
tenant_id,
product_code,
product_name,
price
) VALUES (
:tenant_id,
:product_code,
:product_name,
:price
);
この場合、商品登録後に金額更新するという2段階の処理にしなくてよいため、中途半端な状態が発生しにくくなります。
冪等性を考える前に、そもそも処理を分割する必要があるのかを見直すことも大事です。
Powertoolsを使う場合の注意
Powertools for AWS Lambda の Idempotency Utility を使う場合、基本的には以下の考え方になります。
関数が正常終了したら COMPLETED
関数が例外終了したら COMPLETED にはしない
そのため、以下のように②で例外が発生し、Lambda全体が失敗するなら、通常は処理済みとして扱われません。
@idempotent(...)
def lambda_handler(event, context):
register_product(event) # ① 成功
update_product_price(event) # ② 失敗したら例外
return {"message": "OK"}
一方で、以下のように例外を握りつぶして成功レスポンスを返してしまうと危険です。
@idempotent(...)
def lambda_handler(event, context):
register_product(event)
try:
update_product_price(event)
except Exception:
logger.exception("金額更新に失敗しました")
return {"message": "OK"}
この場合、②が失敗しているのに Lambda としては成功扱いになります。
結果として、冪等性管理上も完了扱いになり、同じ冪等性キーで再実行しても②が実行されない可能性があります。
そのため、Powertoolsを使う場合でも、業務的に未完了なら例外として扱い、成功レスポンスを返さないことが重要です。
失敗を握りつぶさない
業務的に未完了なら例外として扱う
COMPLETED は最後にする
実装時のベストプラクティス
1. 冪等性キーは呼び出し元で発行する
API の場合、リクエスト元に Idempotency-Key を発行してもらうのが扱いやすいです。
Idempotency-Key: <UUID>
バッチや SQS の場合は、呼び出し元が job_id や order_id などの業務IDをイベントに含めます。
Lambda の中で毎回 UUID を生成してしまうと、再実行のたびに別キーになり、冪等性が成立しません。
2. 業務キーがあるなら、それを使う
リクエスト内に注文番号、受付番号、外部システムIDなどがある場合は、それを冪等性キーとして使えることがあります。
tenant_id + order_no
この方式はシンプルで、業務的にも説明しやすいです。
ただし、必ず DB の一意制約などで最終的に守る必要があります。
3. キーには業務的な意味を持たせる
単に UUID を使うだけでなく、以下を組み合わせると安全です。
tenant_id + operation + business_id
例です。
tenant-a#create_order#order-001
tenant-a#grant_point#event-20260628-001
tenant-b#send_invoice#invoice-001
こうすると、別テナント・別処理・別業務IDの衝突を避けやすくなります。
4. リクエスト内容の不一致を検知する
同じ冪等性キーなのに、リクエスト内容が違う場合があります。
1回目のリクエストです。
{
"order_id": "order-001",
"quantity": 1
}
2回目のリクエストです。
{
"order_id": "order-001",
"quantity": 99
}
この場合、同じキーだからといって前回結果を返すのは危険です。
そのため、payload_hash や request_hash を保存して、同じキーで異なるペイロードが来た場合はエラーにするのがよいです。
5. 処理中状態を管理する
重複リクエストは、処理完了後だけでなく、処理中にも発生します。
この場合、2つ目のリクエストは以下のどれかにします。
-
409 Conflictとして返す -
425 Too Earlyのように「処理中」として返す - リトライ可能なエラーにする
- 少し待ってから再取得させる
DynamoDB に冪等性レコードを持つ場合は、IN_PROGRESS のような状態を保存しておくと制御しやすくなります。
6. TTL を設定する
冪等性レコードを永遠に保存すると、DynamoDB のデータが増え続けます。
そのため、TTL を設定します。
| 処理 | TTL の目安 |
|---|---|
| API の短時間リトライ対策 | 数分〜数時間 |
| 注文・決済 | 数日〜数週間 |
| バッチ処理 | 再実行期間に応じて数日 |
| 法的・監査要件がある処理 | 業務要件に従う |
TTL は短すぎると、期限切れ後の再実行で二重処理される可能性があります。
長すぎると、ストレージコストやキー衝突時の扱いが問題になります。
7. 外部APIの冪等性も確認する
Lambda 内で外部APIを呼び出す場合、Lambda 側だけ冪等でも不十分なことがあります。
たとえば決済APIであれば、外部API側にも冪等性キーを渡せる場合があります。
payment_client.charge(
amount=1000,
idempotency_key=idempotency_key,
)
外部APIが冪等性キーをサポートしているなら、Lambda で使っているキーと同じもの、または関連するキーを渡すと安全です。
8. 副作用の順序を意識する
冪等性管理テーブルを更新するタイミングも重要です。
よくある失敗例です。
この場合、実際には処理が完了していないのに、冪等性管理上は完了扱いになります。
逆に、以下も危険です。
外部APIが絡む場合は、以下のような設計を検討します。
- 外部APIにも冪等性キーを渡す
- Outbox パターンを使う
- 処理状態を細かく分ける
- Saga 的に補償処理を設計する
- 完全な exactly-once を目指さず、at-least-once 前提で整合性を取る
9. バッチは1件単位で冪等にする
SQS や Kinesis のバッチ処理では、Lambda が複数レコードをまとめて受け取ります。
悪い例です。
バッチ全体に1つの冪等性キーを付ける
この場合、1件だけ失敗したときの再処理が難しくなります。
基本は、レコード単位で冪等性キーを持つ方が扱いやすいです。
{
"records": [
{
"idempotency_key": "tenant-a#create_order#order-001",
"order_id": "order-001"
},
{
"idempotency_key": "tenant-a#create_order#order-002",
"order_id": "order-002"
}
]
}
SQS では部分失敗レスポンスを使うことで、成功したメッセージまで再処理されることを抑えられます。
ただし、それでも「重複が絶対に来ない」わけではないため、各レコードの処理は冪等にしておくのが安全です。
10. 複数ステップ処理では途中失敗を前提にする
1つの Lambda の中で複数の副作用を起こす場合、途中まで成功して途中で失敗することがあります。
そのため、以下を意識します。
- すべて成功した場合だけ
COMPLETEDにする - 同一DB内ならトランザクションでまとめる
- トランザクションでまとめられないなら、各ステップを個別に冪等化する
- 必要に応じてステップ単位で状態を持つ
- 例外を握りつぶして成功扱いにしない
冪等性キーは「再実行を止めるためのもの」ではなく、再実行されても安全に処理できるようにするためのものです。
問題点・考慮点
1. 冪等性キーの粒度が難しい
キーが粗すぎると、本来別の処理まで重複扱いになります。
tenant-a#customer-001
これだと、同じ顧客の複数注文がすべて同じ扱いになる可能性があります。
逆に細かすぎると、同じ業務リクエストを同一と判断できません。
tenant-a#create_order#order-001#timestamp
timestamp が毎回変わると、再実行時に別キーになります。
2. 「処理済み」と「副作用完了」が一致しない
DB更新、外部API、メール送信などが絡むと、どこまで完了したら COMPLETED とするかが難しくなります。
特に外部システムとの連携では、以下の状態が起こり得ます。
外部APIは成功したが、Lambda はタイムアウトした
この場合、Lambda から見ると失敗ですが、外部システムでは成功しています。
このズレを吸収するには、外部API側の冪等性キー、照会API、状態管理、補償処理が必要になります。
3. IN_PROGRESS が残る
Lambda がタイムアウトしたり、途中で異常終了すると、冪等性テーブルに IN_PROGRESS が残ることがあります。
そのため、in_progress_expiration のようなロック期限が必要です。
status = IN_PROGRESS
in_progress_expiration = 現在時刻 + Lambda timeout + α
期限切れ後は、再実行を許可する、または調査対象にするなどの運用設計が必要です。
4. TTL が短すぎると再実行に弱い
TTL を1時間にした場合、2時間後に同じイベントが再送されると、冪等性レコードが消えていて再処理される可能性があります。
特に以下のケースでは注意が必要です。
- DLQ からの手動再処理
- EventBridge のリトライ
- SQS の長い保持期間
- 障害復旧後の再実行
- バッチの再実行
TTL は、どのくらい過去のイベントが再実行され得るかから逆算して決めるべきです。
5. 冪等性とロックを混同しない
冪等性は「同じ処理が複数回来ても結果を変えない」ための考え方です。
一方、ロックは「同時に処理させない」ための仕組みです。
近いですが、目的が違います。
| 観点 | 冪等性 | ロック |
|---|---|---|
| 目的 | 重複実行の副作用を防ぐ | 同時実行を防ぐ |
| 対象 | 同じ業務リクエスト | 同じリソース |
| 例 | 同じ注文IDは1回だけ処理 | 同じ在庫行を同時更新しない |
| 期間 | TTLや業務要件に依存 | 処理中のみ |
実際のシステムでは、冪等性キーと短時間ロックを組み合わせることが多いです。
6. すべてを exactly-once にしようとしない
分散システムでは、完全な exactly-once をアプリケーション全体で実現するのは難しいです。
現実的には、以下を目指します。
イベントは複数回来る前提で、処理結果が壊れないようにする
つまり、設計思想としては以下です。
at-least-once delivery + idempotent processing
API処理の実装イメージ
API Gateway から通常の Lambda プロキシ統合で呼び出す場合、呼び出し自体は同期です。
ただし、クライアントタイムアウトや再送に備えて、冪等性を考慮します。
2回目以降は以下のように扱います。
業務キーを使った注文登録の実装イメージ
注文番号がリクエストに含まれている場合は、以下のような設計ができます。
この方式では、Idempotency-Key ヘッダーを別途用意しなくても、業務的な注文番号で重複登録を防げます。
ただし、登録前の SELECT だけで守るのではなく、必ず DB の一意制約で最終防衛することが重要です。
複数ステップ処理の実装イメージ
商品登録と金額更新を行う処理を例にします。
同一DB内で完結するなら、可能であれば1つのトランザクションにします。
BEGIN;
INSERT INTO products (
tenant_id,
product_code,
product_name,
price
) VALUES (
:tenant_id,
:product_code,
:product_name,
:price
);
COMMIT;
商品登録と金額更新を分ける必要がある場合でも、以下のように再実行可能にしておきます。
そのためには、商品登録は一意制約で守り、金額更新は「100円加算する」ではなく「価格を1000円にする」のように最終状態を指定するのが望ましいです。
SQS処理の実装イメージ
SQS は Lambda の非同期呼び出しというより、イベントソースマッピングによるポーリング型です。
ただし、メッセージは重複処理される可能性があるため、Lambda 側では冪等性を考慮します。
SQS の場合は、メッセージ単位で成功・失敗を返せるようにしておくと、不要な再処理を減らせます。
まとめ
Lambda の冪等性は、リトライや重複イベントを「異常」として扱うのではなく、起こるものとして設計することが大切だと感じました。
整理すると、ポイントは以下です。
- API Gateway から通常の Lambda プロキシ統合で呼び出す場合は、基本的に同期呼び出し
- API Gateway が同期呼び出しでも、クライアントのタイムアウトや再送によって同じリクエストが複数回来る可能性がある
- EventBridge、S3イベント通知、SNS、SDK の
InvocationType=Eventなどは非同期呼び出しとして扱われる - SQS、Kinesis、DynamoDB Streams はイベントソースマッピングによるポーリング型として整理するとよさそう
- 副作用のある処理では冪等性を考える
- 冪等性が必要かどうかは、副作用の有無、重複時の業務影響、業務キーの有無、外部副作用の有無で判断する
- 冪等性キーは Lambda の実行IDではなく、業務リクエストを識別するIDにする
- API では
Idempotency-Keyを呼び出し元から受け取る方式がある - 注文番号などの一意な業務キーがある場合は、それを冪等性キーとして使える
- まずは業務キーとDB一意制約で守れないかを検討する
- 登録前の検索だけでは同時実行に弱いため、DBの一意制約で最終的に守る
- 同じキー・同じ内容の再送は、リトライ成功として既存データを返す設計もある
- 同じキーで異なる内容が来た場合は
409 Conflictとして扱う - バッチや SQS では
job_id、order_id、event_idなどをイベントに含める - マルチテナントでは
tenant_idをキーに含める - DynamoDB の条件付き書き込みや Powertools の Idempotency Utility を使うと実装しやすい
- SQS FIFO の重複排除だけでは、Lambda 処理後の再実行までは防げない
- 外部APIを呼ぶ場合は、外部API側の冪等性も確認する
- 複数ステップ処理では、すべての処理が成功した場合だけ
COMPLETEDとする - 途中失敗に備えて、トランザクション化、個別冪等化、ステップ状態管理を検討する
- TTL、処理中状態、payload mismatch、DLQ再処理まで考える
Idempotency-Key を使うこと自体が目的ではありません。
大事なのは、以下だと思います。
同じ業務リクエストを安定して識別し、
重複した副作用を起こさないようにすること
そのため、注文番号のような業務キーがあるならそれを使えばよいですし、登録前に業務IDがないなら Idempotency-Key を使う。
複数ステップの処理であれば、途中失敗を前提に、トランザクション化するのか、各ステップを個別に冪等化するのか、ステップ状態を管理するのかを選ぶ必要があります。
Lambda の冪等性は、単なる実装テクニックというより、業務リクエストと副作用をどう安全に結びつけるかの設計問題として考えるのがよさそうです。





