Dynamodbを使うようになったのですが、RDBMSに比べるとDynamodbは雰囲気でやっているところが多く、表題のLimitオプションの挙動で嵌ったのでboto3での例とあわせてまとめておきます。
対象テーブル
order_table = { 'TableName': 'order_table', 'KeySchema': [ { 'AttributeName': 'user_id', 'KeyType': 'HASH' }, { 'AttributeName': 'ordered_at', 'KeyType': 'RANGE' } ], 'AttributeDefinitions': [ { 'AttributeName': 'user_id', 'AttributeType': 'N' }, { 'AttributeName': 'ordered_at', 'AttributeType': 'S' } ], 'ProvisionedThroughput': { 'ReadCapacityUnits': 100, 'WriteCapacityUnits': 100 } }
user_id
とordered_at
をKeyとしてもつorder_table
を定義します。そして以下のように1000行ほどダミーデータを投入しておきます。
table = dynamodb.Table('order_table') with table.batch_writer() as batch: for i in tqdm(range(1000)): batch.put_item( Item = { 'user_id': i % 50, 'ordered_at': (datetime.datetime(2018, 2, 1) + datetime.timedelta(days=(i))).isoformat(), 'shop_id': i% 20, 'category': 'category_{}'.format(i%3), 'order_items': [ 'item_{}'.format(i %20) ] } )
それぞれのデータはこんな感じのフォーマットで各user_id
ごとに20件ずつ入っています。
{ 'category': 'category_1', 'order_items': ['item_1'], 'ordered_at': '2018-02-02T00:00:00', 'shop_id': Decimal('1'), 'user_id': Decimal('1'), }
一応ちゃんと1000件(20件 * 50ユーザ)入っていることも確認しておきます。
table.scan().get('Count') # -> 1000
基本的なクエリ
とりあえずいくつかクエリを実行しておきます。
Key属性での絞込
# user_id = 1 のデータを取得 table.query( KeyConditionExpression=Key('user_id').eq(1) )
{'Count': 20, 'Items': [{'category': 'category_1', 'order_items': ['item_1'], 'ordered_at': '2018-02-02T00:00:00', 'shop_id': Decimal('1'), 'user_id': Decimal('1')}, {'category': 'category_0', 'order_items': ['item_11'], 'ordered_at': '2018-03-24T00:00:00', 'shop_id': Decimal('11'), 'user_id': Decimal('1')}, {'category': 'category_2', 'order_items': ['item_1'], 'ordered_at': '2018-05-13T00:00:00', 'shop_id': Decimal('1'), 'user_id': Decimal('1')}, :
user_id = 1
20件の結果が戻っています。さらに日付で2018-12-31T23:59:59
までという絞込をかけます。
table.query( KeyConditionExpression=Key('user_id').eq(1) & Key('ordered_at').lt('2018-12-31T23:59:59') )
{'Count': 7, 'Items': [{'category': 'category_1', 'order_items': ['item_1'], 'ordered_at': '2018-02-02T00:00:00', 'shop_id': Decimal('1'), 'user_id': Decimal('1')}, {'category': 'category_0', 'order_items': ['item_11'], 'ordered_at': '2018-03-24T00:00:00', 'shop_id': Decimal('11'), 'user_id': Decimal('1')}, {'category': 'category_2', 'order_items': ['item_1'], :
2018年の注文は7件であることがわかりました。またこのうちで5件だけ取得するにはLimit=5
をつければ良いです。
table.query( KeyConditionExpression=Key('user_id').eq(1) & Key('ordered_at').lt('2018-12-31T23:59:59'), Limit=5 )
{'Count': 5, 'Items': [{'category': 'category_1', 'order_items': ['item_1'], 'ordered_at': '2018-02-02T00:00:00', 'shop_id': Decimal('1'), 'user_id': Decimal('1')}, {'category': 'category_0', 'order_items': ['item_11'], 'ordered_at': '2018-03-24T00:00:00', 'shop_id': Decimal('11'), 'user_id': Decimal('1')}, {'category': 'category_2', 'order_items': ['item_1'], 'ordered_at': '2018-05-13T00:00:00', 'shop_id': Decimal('1'), 'user_id': Decimal('1')}, {'category': 'category_1', 'order_items': ['item_11'], 'ordered_at': '2018-07-02T00:00:00', 'shop_id': Decimal('11'), 'user_id': Decimal('1')}, {'category': 'category_0', 'order_items': ['item_1'], 'ordered_at': '2018-08-21T00:00:00', 'shop_id': Decimal('1'), 'user_id': Decimal('1')}],
このケースでのLimitは意図通りの動きです
非Key属性での絞込
先程はuser_id
やordered_at
というKey属性での絞込でした。非Key属性だけで絞込をするにはscan
とFilterExpression
を使用します。
table.scan( FilterExpression=Key('category').eq('category_1') ).get('Count') # -> 333
category=category_1
は333件であることがわかります。ただしscan
はRDBMSでいうところのFULL SCANをした上での絞込をしているため、負荷の高い処理であることに注意します
また、Key属性と非Key属性を併用する場合はquery
でKeyConditionExpression
とFilterExpression
を指定します。
# user_id = 1 and category = 'category_1' table.query( KeyConditionExpression=Key('user_id').eq(1), FilterExpression=Key('category').eq('category_1'), )
Limitに気持ちが伝わらないケース
結論から言うと、FilterExpression
とLimit
の併用のケースがほぼ意図通りにならないので注意しましょう。という話です。
例えば、先程333件が戻った以下のscan
を例にします。
table.scan( FilterExpression=Key('category').eq('category_1') ).get('Count')
これにLimit=5
をつけたときはCount:5
が戻ると思ってしまいます。
table.scan( FilterExpression=Key('category').eq('category_1'), Limit=5 ).get('Count')
しかし意図に反して、このscan
は2を戻しました。結果は状況次第ですが、5以下が戻るのは同じです。これはFilterExpression
とLimit
の評価順によるものです。
http://boto3.readthedocs.io/en/latest/reference/services/dynamodb.html#DynamoDB.Client.query
FilterExpression is applied after a Query finishes, but before the results are returned. A FilterExpression cannot contain partition key or sort key attributes. You need to specify those attributes in the KeyConditionExpression .
FilterExpression
はクエリが終了して取得された結果に反映されるというものです。一方でLimit
はクエリ自体が戻す行数を制限します。そのため、先に評価されるのはLimit
なのです。
つまり、先の例をあげるとorder_table
から5件を取得(Limit=5
)し、その5件についてFilterExpression=Key('category').eq('category_1')
が適用されたため最終的に2件しか取得されなかったのです。
一方でKeyConditionExpression
はクエリ自体に組み込まれるため、KeyConditionExpression
とLimit
の併用はKeyConditionExpression
を満たす結果をLimit
分だけ取り出すという意図した挙動になっているのです。
どうすればいいのか
非効率的ではありますが、以下は意図した結果になります。
table.scan( FilterExpression=Key('category').eq('category_1') ).get('Items', [])[:5]
もっと効率的にやるのであれば非Key属性であるcategory
をKeyConditionExpression
で処理できればいいので、category
を含んだセカンダリーインデックスを作成することが有効です。このあたりはテーブル設計時点で事前にわかっていないこともあるので、難しいところですね。