Amazon DynamoDBのLimit条件はFilterExpressionの前に適用されます
リテールアプリ共創部のるおんです。先日データベースにAmazon DynamoDBを使用しているプロジェクトで、会員登録後にユーザーが「登録済み」と認識されず、何度も登録できてしまうという不具合が発生しました。原因を調査した結果、DynamoDBのQueryにおける Limit と FilterExpression の挙動に起因する問題であることが判明しました。RDBと同じような挙動を期待していたため、意外な仕様でした。
今回はこの問題の原因と解決方法を共有したいと思います。同様の問題に遭遇した方の参考になれば幸いです。
先に結論
DynamoDBのQueryにおいて、Limit パラメータは FilterExpression が適用される前に評価されます。そのため、Limit: 1 を指定した場合、フィルタリング前に1件だけ取得し、その1件がフィルタ条件に合致しなければ結果が0件になってしまいます。
const { Items: userDdbItems } = await this.#ddbDoc.send(
new QueryCommand({
TableName: this.#userTableName,
IndexName: "LineUserIdIndex",
KeyConditionExpression: "lineUserId = :lineUserId",
FilterExpression: "attribute_not_exists(withdrawnAt)",
ExpressionAttributeValues: {
":lineUserId": lineUserId,
},
- Limit: 1,
}),
);
Limit: 1 を削除することで問題は解消しました。
発生していた問題
本プロジェクトにおいて、以下のような現象が発生していました。
- ユーザーが会員登録を完了
- 登録直後、「登録済み」として認識されない
- 再度会員登録画面に遷移でき、もう一度登録できてしまう
- 結果として同じLINEユーザーIDで複数の会員レコードが作成される
この問題は常に発生するわけではなく、特定の条件下でのみ再現していました。
それは、ユーザーが退会した場合、レコードを物理削除するのではなく、withdrawnAt フィールドに退会日時を設定する論理削除方式を採用しているためでした。
システム構成
本システムでは、以下のような構成でユーザー管理を行っています。
DynamoDBテーブル設計
| 項目 | 値 |
|---|---|
| テーブル名 | Users |
| パーティションキー(PK) | userId(UUID v4) |
| GSI | LineUserIdIndex(PK: lineUserId) |
論理削除の仕組み
ユーザーが退会した場合、レコードを物理削除するのではなく、withdrawnAt フィールドに退会日時を設定する論理削除方式を採用しています。
問題のあったコード
会員登録済みかどうかを判定するために、以下のようなクエリを実行していました。
const { Items: userDdbItems } = await this.#ddbDoc.send(
new QueryCommand({
TableName: this.#userTableName,
IndexName: "LineUserIdIndex",
KeyConditionExpression: "lineUserId = :lineUserId",
FilterExpression: "attribute_not_exists(withdrawnAt)",
ExpressionAttributeValues: {
":lineUserId": lineUserId,
},
Limit: 1,
}),
);
このクエリの意図は以下の通りです。
LineUserIdIndexGSIを使用して、指定されたLINEユーザーIDに紐づくレコードを検索FilterExpressionで退会済み(withdrawnAtが存在する)レコードを除外Limit: 1で1件だけ取得(パフォーマンス最適化のつもり)
一見正しそうに見えますが、ここに落とし穴がありました。
原因:Limitの評価順序
DynamoDBにおける Limit パラメータの挙動は、直感に反するものでした。
期待していた挙動
- GSIから該当するレコードを全て取得
FilterExpressionでフィルタリング- フィルタ後の結果から1件を返す
実際の挙動
- GSIから 1件だけ 取得(
Limit: 1の適用) - その1件に対して
FilterExpressionを適用 - フィルタ条件に合致しなければ 0件 を返す
つまり、Limit は FilterExpression の前に適用されます。
公式ドキュメントの記載
この挙動はAWS公式ドキュメント(API Reference - Query)に明確に記載されています。
A single Query operation will read up to the maximum number of items set (if using the Limit parameter) or a maximum of 1 MB of data and then apply any filtering to the results using FilterExpression.
「Limitで指定した件数を読み取った後にFilterExpressionが適用される」と明記されています。
問題の再現シナリオ
具体的にどのような状況で問題が発生するのか説明します。
前提条件
同じLINEユーザーID(U1234567890)で以下の2レコードがGSIに存在する状況を想定します。
| userId | lineUserId | withdrawnAt | 作成順 |
|---|---|---|---|
| user-001 | U1234567890 | 2025-01-01T00:00:00Z | 退会済み(古い) |
| user-002 | U1234567890 | null | アクティブ(新しい) |
問題発生のフロー
LineUserIdIndexGSIに対してQueryを実行- 退会済みの
user-001が最初に返される(この順序は保証されない) Limit: 1により、この1件のみが取得されるFilterExpressionによりwithdrawnAtが存在するため除外される- 結果が0件になる
- アプリは「未登録ユーザー」と判断
- 新規登録処理が実行され、3つ目のレコードが作成される
解決方法
Limit: 1 を削除することで、フィルタリング前に全件取得し、その後フィルタが適用されるようになります。
const { Items: userDdbItems } = await this.#ddbDoc.send(
new QueryCommand({
TableName: this.#userTableName,
IndexName: "LineUserIdIndex",
KeyConditionExpression: "lineUserId = :lineUserId",
FilterExpression: "attribute_not_exists(withdrawnAt)",
ExpressionAttributeValues: {
":lineUserId": lineUserId,
},
- Limit: 1,
}),
);
この修正により、問題は解消しました。
Limit削除後の動作
Limit を指定しない場合、QueryはGSIに該当する全レコードを取得し、その後 FilterExpression が適用されます。結果として配列で返却されますが、アクティブなユーザーは通常1人なので、userDdbItems?.[0] で最初の要素を取得すれば問題ありません。
const { Items: userDdbItems } = await this.#ddbDoc.send(
new QueryCommand({
TableName: this.#userTableName,
IndexName: "LineUserIdIndex",
KeyConditionExpression: "lineUserId = :lineUserId",
FilterExpression: "attribute_not_exists(withdrawnAt)",
ExpressionAttributeValues: {
":lineUserId": lineUserId,
},
}),
);
// 配列で返ってくるので最初の要素を取得
const user = userDdbItems?.[0];
おわりに
今回はDynamoDBの Limit と FilterExpression の組み合わせで発生した不具合について共有しました。
Limit パラメータは一見パフォーマンス最適化に有効に見えますが、FilterExpression との組み合わせではRDBでは発生しないような挙動をしました。DynamoDBの Limit は「フィルタ後の件数」ではなく「評価する件数」を指定するパラメータです。FilterExpression と組み合わせる際は特に注意が必要です。
以上、どなたかの参考になれば幸いです。
参考






