Amazon DynamoDBのLimit条件はFilterExpressionの前に適用されます

Amazon DynamoDBのLimit条件はFilterExpressionの前に適用されます

2026.02.05

リテールアプリ共創部のるおんです。先日データベースにAmazon DynamoDBを使用しているプロジェクトで、会員登録後にユーザーが「登録済み」と認識されず、何度も登録できてしまうという不具合が発生しました。原因を調査した結果、DynamoDBのQueryにおける LimitFilterExpression の挙動に起因する問題であることが判明しました。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 を削除することで問題は解消しました。

発生していた問題

本プロジェクトにおいて、以下のような現象が発生していました。

  1. ユーザーが会員登録を完了
  2. 登録直後、「登録済み」として認識されない
  3. 再度会員登録画面に遷移でき、もう一度登録できてしまう
  4. 結果として同じ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,
  }),
);

このクエリの意図は以下の通りです。

  • LineUserIdIndex GSIを使用して、指定されたLINEユーザーIDに紐づくレコードを検索
  • FilterExpression で退会済み(withdrawnAtが存在する)レコードを除外
  • Limit: 1 で1件だけ取得(パフォーマンス最適化のつもり)

一見正しそうに見えますが、ここに落とし穴がありました。

原因:Limitの評価順序

DynamoDBにおける Limit パラメータの挙動は、直感に反するものでした。

期待していた挙動

  1. GSIから該当するレコードを全て取得
  2. FilterExpression でフィルタリング
  3. フィルタ後の結果から1件を返す

実際の挙動

  1. GSIから 1件だけ 取得(Limit: 1の適用)
  2. その1件に対して FilterExpression を適用
  3. フィルタ条件に合致しなければ 0件 を返す

つまり、LimitFilterExpressionに適用されます。

公式ドキュメントの記載

この挙動は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 アクティブ(新しい)

問題発生のフロー

  1. LineUserIdIndex GSIに対してQueryを実行
  2. 退会済みの user-001 が最初に返される(この順序は保証されない)
  3. Limit: 1 により、この1件のみが取得される
  4. FilterExpression により withdrawnAt が存在するため除外される
  5. 結果が0件になる
  6. アプリは「未登録ユーザー」と判断
  7. 新規登録処理が実行され、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の LimitFilterExpression の組み合わせで発生した不具合について共有しました。

Limit パラメータは一見パフォーマンス最適化に有効に見えますが、FilterExpression との組み合わせではRDBでは発生しないような挙動をしました。DynamoDBの Limit は「フィルタ後の件数」ではなく「評価する件数」を指定するパラメータです。FilterExpression と組み合わせる際は特に注意が必要です。

以上、どなたかの参考になれば幸いです。

参考

https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html
https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.FilterExpression.html
https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html

この記事をシェアする

FacebookHatena blogX

関連記事