1. 結論(この記事で得られること)
この記事では、API Gateway→Lambda→DynamoDBという構成で発生しがちな「書き込んだはずのデータが次の瞬間読めない」「同時リクエストで更新が失われる」といったtiming issueの対策を、実務で本当に使える形で解説します。
得られる具体的な成果:
- 結果整合性とトランザクション分離レベルの違いを体で理解できる
- 楽観的ロック・条件付き書き込みの実装パターンが手に入る
- CloudWatchとX-Rayを使った問題切り分け手法が身につく
- AIを使った効率的なデバッグ・設計検証のフローを習得できる
昔、私もPOSTでユーザー登録した直後のGETで404が返る謎現象に2日ハマりました。あの時にこの知識があれば…という実体験ベースで書いています。
2. 前提(環境・読者層)
想定読者:
- AWSの基本的なサービスは触ったことがある
- LambdaとDynamoDBを使った簡単なCRUDは作れる
- けれど本番で「たまに」起きる不整合に困っている
技術スタック:
- API Gateway (REST API)
- Lambda (Node.js 18.x / Python 3.11想定)
- DynamoDB (On-Demandまたはプロビジョニング)
- AWS SDK v3 (JavaScript) / boto3 (Python)
ローカル環境はDynamoDB Localで再現できる範囲も扱いますが、メインは実環境での対策です。
3. Before:よくあるつまずきポイント
3-1. 「書いた直後に読めない」結果整合性の罠
DynamoDBは結果整合性(Eventually Consistent) がデフォルトです。
// ❌ よくある失敗パターン
exports.handler = async (event) => {
const userId = event.requestContext.authorizer.claims.sub;
// 1. DynamoDBに書き込み
await docClient.send(new PutCommand({
TableName: 'Users',
Item: { userId, name: 'Taro', createdAt: Date.now() }
}));
// 2. 即座に別のLambdaがGETを呼ぶ(別APIやStep Functions経由など)
const result = await docClient.send(new GetCommand({
TableName: 'Users',
Key: { userId }
}));
// 3. result.Item が undefined のことがある!
return { statusCode: 200, body: JSON.stringify(result.Item) };
};
なぜ起きるか:
- DynamoDBは複数のストレージノードにデータを複製
- 書き込みは一部のノードに成功した時点で応答
- 読み込みが別ノードに当たると古いデータ(or 存在しない)を返す
3-2. 同時更新による「Lost Update」
# ❌ 2つのLambdaが同時に実行されると...
def increment_score(user_id):
# 1. 現在のスコアを取得
response = table.get_item(Key={'userId': user_id})
current_score = response['Item']['score']
# 2. +10する
new_score = current_score + 10
# 3. 書き戻す(この間に別Lambdaも同じことをする)
table.put_item(Item={'userId': user_id, 'score': new_score})
実際に起きること:
- Lambda A: スコア100を読む → 110に更新
- Lambda B: スコア100を読む → 110に更新(Aの更新を知らない)
- 結果:本来120になるべきが110になる
3-3. API Gateway→Lambda間のタイムアウト設定ミス
API Gateway (29秒タイムアウト)
↓
Lambda (30秒タイムアウト)
↓
DynamoDB (25秒かかる重いクエリ)
このケースだと、Lambdaは正常に書き込み完了してもAPI Gatewayが先にタイムアウトし、クライアントはエラーと判断。リトライで二重登録が発生します。
4. After:基本的な解決パターン
4-1. Strong Consistent Readで即座に読み取る
// ✅ 書き込み直後に確実に読む
await docClient.send(new PutCommand({
TableName: 'Users',
Item: { userId, name: 'Taro' }
}));
const result = await docClient.send(new GetCommand({
TableName: 'Users',
Key: { userId },
ConsistentRead: true // 👈 これで最新データを保証
}));
トレードオフ:
- レイテンシが若干増加(通常1-2ms程度)
- 読み込みキャパシティを2倍消費
- でも整合性が必要な場面では必須
4-2. 条件付き書き込みで楽観的ロック
# ✅ バージョン番号を使った安全な更新
try:
table.update_item(
Key={'userId': user_id},
UpdateExpression='SET score = score + :inc, version = version + :v',
ConditionExpression='version = :current_version',
ExpressionAttributeValues={
':inc': 10,
':v': 1,
':current_version': expected_version
}
)
except ClientError as e:
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
# 別の更新があった → リトライまたはエラー返却
return {'statusCode': 409, 'body': 'Conflict'}
4-3. DynamoDB Transactionsで複数操作を原子化
// ✅ 在庫減少と注文作成を同時に成功させる
const command = new TransactWriteCommand({
TransactItems: [
{
Update: {
TableName: 'Inventory',
Key: { productId: 'ABC' },
UpdateExpression: 'SET stock = stock - :qty',
ConditionExpression: 'stock >= :qty',
ExpressionAttributeValues: { ':qty': 1 }
}
},
{
Put: {
TableName: 'Orders',
Item: { orderId: uuid(), productId: 'ABC', userId }
}
}
]
});
try {
await docClient.send(command);
} catch (err) {
if (err.name === 'TransactionCanceledException') {
// 在庫不足などでロールバックされた
}
}