DynamoDBのLastEvaluatedKeyが示すレコードを削除した時の挙動について調べてみた

おつかれさまです。 最近、ドラマ「白い巨塔」を見て禁煙をはじめた新井@サーバーレス開発部です。

唐突ですが、みなさんはDynamoDBのLastEvaluatedKeyについてはご存知でしょうか? ※知らないという方はこちらをご覧ください。

簡単に言ってしまうと、DynamoDBのScanやQueryの操作で全件取得しきれなかった時に返却される、次点の開始位置を示すキーです。

DynamoDBのScanやQueryで全件取得しきれなかったり、最大取得件数(Limit)を設定してベージネーションをしているときなど、よく見かける方も多いのではないでしょうか。

ちなみに、boto3のレスポンスだとこんな感じにLastEvaluatedKeyの情報が返却されます。

{
  'Items': [{'id': Decimal('1'), 'name': 'charlie'}],
  'Count': 1,
  'ScannedCount': 1,
  'LastEvaluatedKey': {'id': Decimal('1'), 'name': 'charlie'}
...

で、今回は

「ページネーションを行っている間に、このキーが指すデータポイントが削除/更新された場合の挙動ってどうなるんだろう?」

「データ取得時にエラーになるのでは?」

「不整合がおきる?」

と気になったため調べてみた際のメモになります。

先に結論

結論から言うと、レスポンスデータが返されて次のリクエストを送るまでの間に、lastEvaluatedKeyが指しているデータが削除、変更されたとしてもなんら問題ありません。 (※というか、そもそも変更はできません。詳しくは後述)

lastEvaluatedKeyはデータそのものを参照しているのではなく、あくまで開始位置を示しているだけだからです。

データが削除されようが、更新されようが、前後に新しいデータが追加されようが、開始位置は変わりません。更新タイミングによってはデータの取得漏れがありますが、そこは割り切りですね。

やってみた

下記のテーブルに対し、Scan操作を実施していきます。

  • テーブル名: arai-test-table
    • HashKey: id (Number)
    • RangeKey: name (String)
id name
1 alice
1 bob
1 charlie
2 dave
2 eve
3 frank

Scanした時の挙動

  • Python コード
import boto3
import time

dynamodb = boto3.resource('dynamodb', region_name='ap-northeast-1')

table = dynamodb.Table('arai-test-table')
last_key = None

while True:
  params = {'Limit':1}
  if last_key:
    params['ExclusiveStartKey'] = last_key

  response = table.scan(**params)
  if 'LastEvaluatedKey' not in response:
    break

  last_key = response['LastEvaluatedKey']
  print('====================ITEMS====================')
  print('Items', response['Items'], sep='=')
  print('LastEvaluatedKey', last_key, sep='=')
  time.sleep(3)
  • 出力
python ddb_scan.py
====================ITEMS====================
Items=[{'id': Decimal('3'), 'name': 'frank'}]
LastEvaluatedKey={'id': Decimal('3'), 'name': 'frank'}
====================ITEMS====================
Items=[{'id': Decimal('2'), 'name': 'dave'}]
LastEvaluatedKey={'id': Decimal('2'), 'name': 'dave'}
====================ITEMS====================
Items=[{'id': Decimal('2'), 'name': 'eve'}]
LastEvaluatedKey={'id': Decimal('2'), 'name': 'eve'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'alice'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'alice'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'bob'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'bob'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'charlie'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'charlie'}

Scanしたときの返却値の順番ってこんな感じになるんですね。。。

開始位置のItemを削除してみる

aliceまで読み込んだ後に、DynamoDBから{'id': Decimal('1'), 'name': 'alice'}のレコードを削除してみます。

次点の検索では、ExclusiveStartKeyaliceのレコードを指しています。

$ python ddb_scan.py
====================ITEMS====================
Items=[{'id': Decimal('3'), 'name': 'frank'}]
LastEvaluatedKey={'id': Decimal('3'), 'name': 'frank'}
====================ITEMS====================
Items=[{'id': Decimal('2'), 'name': 'dave'}]
LastEvaluatedKey={'id': Decimal('2'), 'name': 'dave'}
====================ITEMS====================
Items=[{'id': Decimal('2'), 'name': 'eve'}]
LastEvaluatedKey={'id': Decimal('2'), 'name': 'eve'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'alice'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'alice'}
...
# ここで、{id:1, name: alice}のレコードを削除
...
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'bob'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'bob'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'charlie'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'charlie'}

なんら問題なく動作しました。

開始位置のItemを更新してみる

aliceまで読み込んだ後に、aliceの名前をaraiに変更して...

と考えていましたが、よく考えたらlastEvaluatedKeyにはユニークなプライマリーキーが返却されるので、プライマリーキーの変更はできません

開始位置の前後にItem追加してみる

だいたい予想できるかとおもいますが、一応試してみます。

aliceまで読み込んだ後に、araiabeというレコードを挿入してみます。

id name
1 abe
1 arai
$ python ddb_scan.py
====================ITEMS====================
Items=[{'id': Decimal('3'), 'name': 'frank'}]
LastEvaluatedKey={'id': Decimal('3'), 'name': 'frank'}
====================ITEMS====================
Items=[{'id': Decimal('2'), 'name': 'dave'}]
LastEvaluatedKey={'id': Decimal('2'), 'name': 'dave'}
====================ITEMS====================
Items=[{'id': Decimal('2'), 'name': 'eve'}]
LastEvaluatedKey={'id': Decimal('2'), 'name': 'eve'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'alice'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'alice'}
...
# ここで、{id:1, name: abe}と{id:1, name: arai}のレコードを挿入
...
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'arai'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'arai'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'bob'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'bob'}
====================ITEMS====================
Items=[{'id': Decimal('1'), 'name': 'charlie'}]
LastEvaluatedKey={'id': Decimal('1'), 'name': 'charlie'}

ソート順的に、aliceの後に来るaraiの方のレコードは取得できていますね。

まとめ

LastEvaluatedKeyExclusiveStartKeyはあくまで、開始位置終了位置を示しているだけで、直接データを参照しているわけではないということですね。

開始位置にデータがあろうがなかろうが関係ないということです。

ちなみに、Queryでのテーブル操作の場合も挙動は同じでした!

よく考えてみれば当たり前なのですが、普段意識しない部分なので、動きが確認できてよかったです。あらめてDynamoDBとお近づきになれた気がします。

以上、どなたかの役に立てば幸いです。お疲れ様でした!

余談

AWS CLIから利用した場合は、LastEvaluatedKeyではなくNextTokenが返却されました。

$ aws dynamodb scan \
--table-name arai-test-table \
--max-items 1
{
    "Items": [
        {
            "id": {
                "N": "3"
            },
            "name": {
                "S": "frank"
            }
        }
    ],
    "Count": 6,
    "ScannedCount": 6,
    "ConsumedCapacity": null,
    "NextToken": "eyJFeGNsdXNpdmVTdGFydEtleSI6IG51bGwsICJib3RvX3RydW5jYXRlX2Ftb3VudCI6IDF9"
}
$ echo -n "eyJFeGNsdXNpdmVTdGFydEtleSI6IG51bGwsICJib3RvX3RydW5jYXRlX2Ftb3VudCI6IDF9" | base64 -d
{"ExclusiveStartKey": null, "boto_truncate_amount": 1}

boto_truncate_amountこいつが何なのかちゃんと調べられていませんが、同じく開始位置を示しているっぽいのはわかります。ちなみにtrancated=「切り捨て」のことなので、普通に考えればoffsetのような扱いなんだと思います。

AWS CLIの場合は、コマンドに--starting-tokenオプションを追加し、NextTokenの値をセットしてあげることで、途中からデータ取得を再開できます。