AWS
python3
DynamoDB

DynamoDBをPython(boto3)を使って試してみた

はじめに

AWSのマネージドNoSQLであるDynamoDBについて調べたことをまとめてみました。
RDBMS暦が長いと、なかなかに難しいですね。

理論

キーの考え方

DynamoDBでレコード(Item)を一意に決定するプライマリキーには以下の2通りがあります。

  • パーティションキー

  • パーティションキー+ソートキー

パーティションキーはハッシュキー、ソートキーはレンジキーと呼ばれていたりします。
パーティションキーといった方がしっくりきますね。

パーティションキーはその名の通り、パーティションを分割して保存されます。パーティションキーが異なるItemは独立性が高く、パーティションキーを横断した処理はDynamoDBは苦手とします。

一方、ソートキーは同じパーティション内で順序を含めて保存されます。
同一パーティションキー内のデータをユニークに決定する属性です。

例として某アイドルのテーブルを考えてみます。

image

独立性の高いパーティションキー。

image

ソートキーを組み合わせてItemを一意になります。

image

属性と型

DynamoDBは基本的にスキーマレスなのでキー以外の属性については定義する必要はありません。

使用できる型は以下の通りです。

  • 文字列
  • 数値
  • バイナリ
  • Boolean
  • null
  • リスト
  • マップ
  • セット

ScanとQuery

DynamoDBのテーブルからデータを取得(Select)する操作にはScanとQueryがあります。

Scanは全件取得、Queryはキーで絞り込んで取得する処理です。
Scanはコストがかかる(料金的な意味も含めて)ので、避けてQueryを使用するようにテーブル設計したほうが良いようです。

インデックス

非キー属性で絞り込む場合、インデックス(セカンダリインデックス)を事前に設定しておく必要があります。

セカンダリインデックスには以下の2つがあります

  • ローカルセカンダリインデックス(LSI)

  • グローバルセカンダリインデックス(GSI)

LSIは同一パーティション内の属性を絞り込みます。そのため、代替ソートキーということになります。
GSIはパーティションをまたがって属性を絞り込むことができます。

どちらも1つのテーブルに5つまで作成できますが、LSIはテーブルの作成時にしか設定できません
※ 今後のアップデートでLSIも後から変更可能になるとのことです。

インデックスとは別にフィルタリングの機能があります。
ただし、これはScanまたはQueryで取得した結果に対するフィルタリングのため、コストは下がりません。
また、Limitと併用した場合、Limitで制限して取得した後にフィルタリングされます。

インデックステーブルに保存される属性は元のテーブルの属性のサブセットとなります(射影と呼びます)。
インデックス作成時に指定できますが、インデックスの属性に含めるのか、インデックスからはキーのみを取得し、基のテーブルからキーで引き直すのかは、設計時のポイントになりそうです。

LSI

実質的に元のテーブルとソートキーが異なる、トリガーで管理されたテーブルを作成することと同意です。
パーティションキーは同じでなくてはなりません。
そのため、必ずパーティションキーとソートキーの複合キーのテーブルとなります。

元のテーブルのItemが変更されると、自動でインデックスのテーブルも更新されます。

1つのテーブルに5つのインデックスが作成できますが、それぞれのインデックスは独立しているため、複数のインデックスを組み合わせた絞り込みはできません。

image

スループットはベーステーブルと共用です。

GSI

グローバルセカンダリインデックスはLSIのようなパーティションキーに依存することはありません。
指定した属性をプライマリキーとするインデックスのテーブルが作成されます。

image

その他の制限は LSI と同様です。

スループットは LSI と違い、独立しています。

実践

実際に触って確認してみます。
環境はPython3.6とboto3を使用し、Cloud9から実行しています。

テーブルの作成

create_tableで行います。

def create_table(resource):
    """
    テーブルの作成

    """
    resource.create_table(
        TableName = 'idolmaster',
        KeySchema = [
            {
                'AttributeName': 'series',
                'KeyType': 'HASH'
            },
            {
                'AttributeName': 'name',
                'KeyType': 'RANGE'
            },
        ],
        AttributeDefinitions = [
            {
                'AttributeName': 'series',
                'AttributeType': 'S'
            },
            {
                'AttributeName': 'name',
                'AttributeType': 'S'
            },
            {
                'AttributeName': 'type',
                'AttributeType': 'S'
            },
            {
                'AttributeName': 'birthday',
                'AttributeType': 'S'
            },
            {
                'AttributeName': 'height',
                'AttributeType': 'N'
            },
        ],
        ProvisionedThroughput = {
            'ReadCapacityUnits': 1,
            'WriteCapacityUnits': 1
        },
        LocalSecondaryIndexes=[
            {
                'IndexName': 'typeLSIndex',
                'KeySchema': [
                    {
                        'AttributeName': 'series',
                        'KeyType': 'HASH'
                    },
                    {
                        'AttributeName': 'type',
                        'KeyType': 'RANGE'
                    }
                ],
                'Projection': {
                    'ProjectionType': 'INCLUDE',
                    'NonKeyAttributes': [
                        'name',
                    ]
                }
            },
        ],
        GlobalSecondaryIndexes=[
            {
                'IndexName': 'birthHeightGSIndex',
                'KeySchema': [
                    {
                        'AttributeName': 'birthday',
                        'KeyType': 'HASH'
                    },
                    {
                        'AttributeName': 'height',
                        'KeyType': 'RANGE'
                    },
                ],
                'Projection': {
                    'ProjectionType': 'KEYS_ONLY',
                },
                'ProvisionedThroughput': {
                    'ReadCapacityUnits': 1,
                    'WriteCapacityUnits': 1
                }
            },
        ],
    )

AttributeDefinitions で属性の型定義を行います。キーとなる属性のみを定義すればよいのですが、セカンダリインデックスを作成する場合は、そのキー属性の定義も必要になります。

LocalSecondaryIndexes の KeySchema ではHASHキーは基のテーブルと同じ指定となります。自明ですが、指定しないとエラーとなります。

レコード(Item)の登録

put_itemで行います。

def put(resource):
    """
    レコードの登録

    """
    table = resource.Table("idolmaster")
    with table.batch_writer() as batch:
        batch.put_item(
            Item={
                'series': 'シンデレラガールズ',
                'name': '島村卯月',
                'type': 'キュート',
                'birthday': '0424',
                'height': 159,
                'home': '東京'
            }
        )
        batch.put_item(
            Item={
                'series': 'シンデレラガールズ',
                'name': '渋谷凛',
                'type': 'クール',
                'birthday': '0810',
                'height': 165,
                'home': '東京'
            }
        )
        batch.put_item(
            Item={
                'series': 'シンデレラガールズ',
                'name': '本田未央',
                'type': 'パッション',
                'birthday': '1201',
                'height': 161,
                'home': '千葉'
            }
        )

        batch.put_item(
            Item={
                'series': 'SideM',
                'name': '天ヶ瀬冬馬',
                'type': 'フィジカル',
                'birthday': '0303',
                'height': 175,
                'home': '神奈川'
            }
        )
        batch.put_item(
            Item={
                'series': 'SideM',
                'name': '伊集院北斗',
                'type': 'インテリ',
                'birthday': '0214',
                'height': 180,
                'home': '京都'
            }
        )
        batch.put_item(
            Item={
                'series': 'SideM',
                'name': '御手洗翔太',
                'type': 'メンタル',
                'birthday': '0420',
                'height': 163,
                'home': '東京'
            }
        )

        batch.put_item(
            Item={
                'series': 'ミリオンライブ',
                'name': '箱崎星梨花',
                'type': 'エンジェル',
                'birthday': '0220',
                'height': 146
            }
        )
        batch.put_item(
            Item={
                'series': 'ミリオンライブ',
                'name': '北沢志保',
                'type': 'フェアリー',
                'birthday': '0118',
                'height': 161
            }
        )
        batch.put_item(
            Item={
                'series': 'ミリオンライブ',
                'name': '七尾百合子',
                'type': 'プリンセス',
                'birthday': '0318',
                'height': 154

            }
        )

複数 put_itemしたいときは batch_writer を使います。

レコード(Item)の取得

Scan

いくつかのパターンで実行してみました。

def scan(resource):
    """
    Scan

    """

    table = resource.Table("idolmaster")

    print('-----------------------------------')
    print('case1 全件取得')
    result = table.scan()
    dump(result)

    print('-----------------------------------')
    print('case2 Filter')
    result = table.scan(
        FilterExpression=Attr('home').eq('東京')
    )
    dump(result)

    print('-----------------------------------')
    print('case3 Filter or ')
    result = table.scan(
        FilterExpression=Attr('home').eq('東京') | Key('series').eq('シンデレラガールズ')
    )
    dump(result)

    print('-----------------------------------')
    print('case4 Filter and ')
    result = table.scan(
        FilterExpression=Attr('home').eq('東京') & Key('series').eq('シンデレラガールズ')
    )
    dump(result)

    print('-----------------------------------')
    print('case5 Filter and 2 ')
    result = table.scan(
        FilterExpression=Attr('home').eq('東京') & Attr('series').eq('シンデレラガールズ')
    )
    dump(result)

    print('-----------------------------------')
    print('case6 Limit')
    result = table.scan(
        FilterExpression=Attr('series').eq('ミリオンライブ'),
        Limit=2
    )
    dump(result)

    print('-----------------------------------')
    print('case7 Limit 2')
    result = table.scan(
        FilterExpression=Attr('series').eq('ミリオンライブ'),
        Limit=10
    )
    dump(result)

Filter の And と Or は Python の and と or ではなく、& と | を使います。
Filter での属性の指定は Attr('属性名')を使います。

実行結果は以下の通りです。


-----------------------------------
case1 全件取得
-----size:9
[
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "height": "159",
        "birthday": "0424",
        "type": "キュート",
        "home": "東京"
    },
    {
        "series": "シンデレラガールズ",
        "name": "本田未央",
        "height": "161",
        "birthday": "1201",
        "type": "パッション",
        "home": "千葉"
    },
    {
        "series": "シンデレラガールズ",
        "name": "渋谷凛",
        "height": "165",
        "birthday": "0810",
        "type": "クール",
        "home": "東京"
    },
    {
        "series": "SideM",
        "name": "伊集院北斗",
        "height": "180",
        "birthday": "0214",
        "type": "インテリ",
        "home": "京都"
    },
    {
        "series": "SideM",
        "name": "天ヶ瀬冬馬",
        "height": "175",
        "birthday": "0303",
        "type": "フィジカル",
        "home": "神奈川"
    },
    {
        "series": "SideM",
        "name": "御手洗翔太",
        "height": "163",
        "birthday": "0420",
        "type": "メンタル",
        "home": "東京"
    },
    {
        "series": "ミリオンライブ",
        "name": "七尾百合子",
        "height": "154",
        "birthday": "0318",
        "type": "プリンセス"
    },
    {
        "series": "ミリオンライブ",
        "name": "北沢志保",
        "height": "161",
        "birthday": "0118",
        "type": "フェアリー"
    },
    {
        "series": "ミリオンライブ",
        "name": "箱崎星梨花",
        "height": "146",
        "birthday": "0220",
        "type": "エンジェル"
    }
]
-----------------------------------
case2 Filter
-----size:3
[
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "height": "159",
        "birthday": "0424",
        "type": "キュート",
        "home": "東京"
    },
    {
        "series": "シンデレラガールズ",
        "name": "渋谷凛",
        "height": "165",
        "birthday": "0810",
        "type": "クール",
        "home": "東京"
    },
    {
        "series": "SideM",
        "name": "御手洗翔太",
        "height": "163",
        "birthday": "0420",
        "type": "メンタル",
        "home": "東京"
    }
]
-----------------------------------
case3 Filter or 
-----size:4
[
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "height": "159",
        "birthday": "0424",
        "type": "キュート",
        "home": "東京"
    },
    {
        "series": "シンデレラガールズ",
        "name": "本田未央",
        "height": "161",
        "birthday": "1201",
        "type": "パッション",
        "home": "千葉"
    },
    {
        "series": "シンデレラガールズ",
        "name": "渋谷凛",
        "height": "165",
        "birthday": "0810",
        "type": "クール",
        "home": "東京"
    },
    {
        "series": "SideM",
        "name": "御手洗翔太",
        "height": "163",
        "birthday": "0420",
        "type": "メンタル",
        "home": "東京"
    }
]
-----------------------------------
case4 Filter and 
-----size:2
[
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "height": "159",
        "birthday": "0424",
        "type": "キュート",
        "home": "東京"
    },
    {
        "series": "シンデレラガールズ",
        "name": "渋谷凛",
        "height": "165",
        "birthday": "0810",
        "type": "クール",
        "home": "東京"
    }
]
-----------------------------------
case5 Filter and 2 
-----size:2
[
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "height": "159",
        "birthday": "0424",
        "type": "キュート",
        "home": "東京"
    },
    {
        "series": "シンデレラガールズ",
        "name": "渋谷凛",
        "height": "165",
        "birthday": "0810",
        "type": "クール",
        "home": "東京"
    }
]
-----------------------------------
case6 Limit
-----size:0
[]
-----------------------------------
case7 Limit 2
-----size:3
[
    {
        "series": "ミリオンライブ",
        "name": "七尾百合子",
        "height": "154",
        "birthday": "0318",
        "type": "プリンセス"
    },
    {
        "series": "ミリオンライブ",
        "name": "北沢志保",
        "height": "161",
        "birthday": "0118",
        "type": "フェアリー"
    },
    {
        "series": "ミリオンライブ",
        "name": "箱崎星梨花",
        "height": "146",
        "birthday": "0220",
        "type": "エンジェル"
    }
]

Query

queryについても同様です。
コメントアウトしているところは、実行するとエラーとなります。

def query(resource):
    """
    Query

    """

    table = resource.Table("idolmaster")

    print('-----------------------------------')
    print('case1 no param -> error')
    # result = table.query()
    # dump(result)

    print('-----------------------------------')
    print('case2 Key')
    result = table.query(
        KeyConditionExpression=Key('series').eq('SideM')
    )
    dump(result)

    print('-----------------------------------')
    print('case3 Attr -> error')
    #result = table.query(
    #    KeyConditionExpression=Attr('series').eq('SideM')
    #)
    #dump(result)


    print('-----------------------------------')
    print('case4 KeyConditionExpression no key -> error ')
    #result = table.query(
    #    KeyConditionExpression=Key('home').eq('東京')
    #)
    #dump(result)

    print('-----------------------------------')
    print('case5 hash no eq -> error')
    #result = table.query(
    #    KeyConditionExpression=Key('series').lt('A')
    #)
    #dump(result)

    print('-----------------------------------')
    print('case6 hash and range ')
    result = table.query(
        KeyConditionExpression=Key('series').eq('シンデレラガールズ') & Key('name').begins_with('島')
    )
    dump(result)

    print('-----------------------------------')
    print('case7 hash or range --> erro ')
    #result = table.query(
    #    KeyConditionExpression=Key('series').eq('A') | Key('name').eq('島')
    #)
    #dump(result)

    print('-----------------------------------')
    print('case8 onluy range --> error')
    #result = table.query(
    #    KeyConditionExpression=Key('name').eq('島')
    #)
    #dump(result)

パーティションキーにはeq条件しか使用できません。
また、ソートキーの条件はパーティションキーの条件を指定した上でないと使用できません。

実行結果は以下の通りです。

-----------------------------------
case1 no param -> error
-----------------------------------
case2 Key
-----size:3
[
    {
        "series": "SideM",
        "name": "伊集院北斗",
        "height": "180",
        "birthday": "0214",
        "type": "インテリ",
        "home": "京都"
    },
    {
        "series": "SideM",
        "name": "天ヶ瀬冬馬",
        "height": "175",
        "birthday": "0303",
        "type": "フィジカル",
        "home": "神奈川"
    },
    {
        "series": "SideM",
        "name": "御手洗翔太",
        "height": "163",
        "birthday": "0420",
        "type": "メンタル",
        "home": "東京"
    }
]
-----------------------------------
case3 Attr -> error
-----------------------------------
case4 KeyConditionExpression no key -> error 
-----------------------------------
case5 hash no eq -> error
-----------------------------------
case6 hash and range 
-----size:1
[
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "height": "159",
        "birthday": "0424",
        "type": "キュート",
        "home": "東京"
    }
]
-----------------------------------
case7 hash or range --> erro 
-----------------------------------
case8 onluy range --> error

インデックスを利用したレコード(Item)の取得

LSI

インデックステーブルに対してqueryを実行するイメージです。

def local_index(resource):
    """
    Local Secondary Index

    """
    index = resource.Table('idolmaster')

    print('-----------------------------------')
    print('case1 scan')
    result = index.scan(
        IndexName='typeLSIndex'
    )
    dump(result)

    print('-----------------------------------')
    print('case2 base key --> error')
    #result = index.query(
    #    IndexName='typeLSIndex',
    #    KeyConditionExpression=Key('series').eq('シンデレラガールズ') & Key('name').begins_with('島')
    #)
    #dump(result)

    print('-----------------------------------')
    print('case3 query')
    result = index.query(
        IndexName='typeLSIndex',
        KeyConditionExpression=Key('series').eq('ミリオンライブ') & Key('type').begins_with('フ')
    )
    dump(result)

実行結果は以下の通りです。

-----------------------------------
case1 scan
-----size:9
[
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "type": "キュート"
    },
    {
        "series": "シンデレラガールズ",
        "name": "渋谷凛",
        "type": "クール"
    },
    {
        "series": "シンデレラガールズ",
        "name": "本田未央",
        "type": "パッション"
    },
    {
        "series": "SideM",
        "name": "伊集院北斗",
        "type": "インテリ"
    },
    {
        "series": "SideM",
        "name": "天ヶ瀬冬馬",
        "type": "フィジカル"
    },
    {
        "series": "SideM",
        "name": "御手洗翔太",
        "type": "メンタル"
    },
    {
        "series": "ミリオンライブ",
        "name": "箱崎星梨花",
        "type": "エンジェル"
    },
    {
        "series": "ミリオンライブ",
        "name": "北沢志保",
        "type": "フェアリー"
    },
    {
        "series": "ミリオンライブ",
        "name": "七尾百合子",
        "type": "プリンセス"
    }
]
-----------------------------------
case2 base key --> error
-----------------------------------
case3 query
-----size:1
[
    {
        "series": "ミリオンライブ",
        "name": "北沢志保",
        "type": "フェアリー"
    }
]

GSI

def global_index(resource):
    """
    Global Secondary Index

    """
    index = resource.Table('idolmaster')

    print('-----------------------------------')
    print('case1 scan')
    result = index.scan(
        IndexName='birthHeightGSIndex'
    )
    dump(result)

    print('-----------------------------------')
    print('case2 query')
    result = index.query(
        IndexName='birthHeightGSIndex',
        KeyConditionExpression=Key('birthday').eq('0118')
    )
    dump(result)

取得できる属性は基のテーブルのキーとパーティションのキーのみとなっています。
実行結果は以下の通りです。

-----------------------------------
case1 scan
-----size:9
[
    {
        "series": "ミリオンライブ",
        "name": "北沢志保",
        "height": "161",
        "birthday": "0118"
    },
    {
        "series": "シンデレラガールズ",
        "name": "渋谷凛",
        "height": "165",
        "birthday": "0810"
    },
    {
        "series": "SideM",
        "name": "御手洗翔太",
        "height": "163",
        "birthday": "0420"
    },
    {
        "series": "ミリオンライブ",
        "name": "箱崎星梨花",
        "height": "146",
        "birthday": "0220"
    },
    {
        "series": "ミリオンライブ",
        "name": "七尾百合子",
        "height": "154",
        "birthday": "0318"
    },
    {
        "series": "シンデレラガールズ",
        "name": "島村卯月",
        "height": "159",
        "birthday": "0424"
    },
    {
        "series": "SideM",
        "name": "天ヶ瀬冬馬",
        "height": "175",
        "birthday": "0303"
    },
    {
        "series": "SideM",
        "name": "伊集院北斗",
        "height": "180",
        "birthday": "0214"
    },
        "series": "シンデレラガールズ",
        "name": "本田未央",
        "height": "161",
        "birthday": "1201"
    }
]
-----------------------------------
case2 query
-----size:1
[
    {
        "series": "ミリオンライブ",
        "name": "北沢志保",
        "height": "161",
        "birthday": "0118"
    }
]

おわりに

ドキュメントを読み直すたびに新しい発見があります。それだけ理解できていないということだと思います。
制限やスループットの話もまとめたかったのですが、疲れたのでまたの機会にします。

参考

Amazon DynamoDB とは - Amazon DynamoDB

Boto 3 Documentation — Boto 3 Docs 1.7.2 documentation