コンセプトから学ぶAmazon DynamoDB【GSI篇】

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

よく訓練されたアップル信者、都元です。今回はグローバル・セカンダリ・インデックス(GSI)にフォーカスします。LSIを忘れないうちにGSIいきますよっ。

ローカル・セカンダリ・インデックス(LSI)というのは、ハッシュキーattributeが共通で別のレンジキーattributeを持つ、複合キーテーブルに対するインデックスでした。

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

例えば、Amazon DynamoDB Developer Guide - サンプルテーブルとデータにあるProductCatalogテーブルは、Idがハッシュキーとなったハッシュキーテーブルです。つまりこのテーブルは、Idを条件としたquery(問い合わせ)しかできません。ハッシュキーテーブルであるが故に、LSIも定義できません。

アプリケーション要件「ProductCategoryで絞り込んだ後、Titleで前方一致検索したい」

ここで、こんなアプリケーション要件が出てきた場合、どうすればいいでしょうか。ここで使うのがGSIです。そしてGSIというのは、テーブルに対して自由に追加できるインデックスです。

GSIはハッシュキーテーブル及び複合キーテーブルどちらにでも設定可能で、LSIのように「ハッシュキーはテーブルのキーと共通でなければならない」等の制約がありません。

さらにもうひとつ考えることがあります。Idは重複する可能性が無い、キーとして適切なattributeでした。一方、「ProductCategoryTitle」は重複の可能性がある、と仮定してみましょう。つまりこのテーブルでは「ProductCategoryTitle」をキーに使った複合キーテーブルとしては設計できないのです。

しかし、GSIはキーではなく、インデックスです *1。つまり、「ProductCategoryTitle」をGSIに指定することは可能です。

結果として、テーブルのキーとしてはIdをハッシュキーとしておきつつ、それとは別途GSIとして、ProductCategoryをハッシュキー、Titleをレンジキーとしたインデックスを定義できます。

アプリケーション要件「BicycleTypeで絞り込んだ後、Priceで範囲検索したい」

さて…。BicycleTypeattributeは全てのアイテムに含まれる訳ではありません。こんなattributeを使ってGSIは定義できるのか? できるんです。

しつこいですが、GSIはキーではなく、インデックスです。全てのアイテムが、BicycleTypePriceを使って表現できる必要は無いのです。そしてもちろん、同じタイプで同じ値段のアイテム自転車は普通にあり得るでしょう。そこに一意性も必要ありません。

結果として、2つ目のGSIとして、BicycleTypeをハッシュキー、Priceをレンジキーとしたインデックスを定義できます。

ちなみに、このインデックスにはBicycleTypeattributeを持たないアイテムは含まれません。つまり、サンプルデータは全8件ですが、「インデックスに対してScan(全件取得)操作をしても、5件のアイテムしか返ってこない」ということです。

GSIを作成してみる

既存のProductCatalogテーブルに対して、上記2つのGSIを追加してみましょう。そう、LSIはテーブル作成時にしか定義できませんでしたが、GSIは後から任意のタイミングで追加と削除ができます。ゴイスー。

ただし、GSIの作成は、1テーブルにつき1つ以上同時進行ができません。GSIの追加には少々時間が掛かりますので、下記の2つのコマンドはちょっと時間を開けて(数十秒〜数分)実行してください。(作成中はdescribe-tableしてみると、IndexStatus が CREATING となっています。ACTIVEになれば完了です。)

$ aws dynamodb update-table \
    --table-name ProductCatalog \
    --attribute-definitions \
      AttributeName=ProductCategory,AttributeType=S \
      AttributeName=Title,AttributeType=S \
      AttributeName=BicycleType,AttributeType=S \
      AttributeName=Price,AttributeType=N \
    --global-secondary-index-updates '[{
      "Create": {
        "IndexName": "ProductCategory-Title-index",
        "KeySchema": [
          { "AttributeName": "ProductCategory", "KeyType": "HASH" },
          { "AttributeName": "Title", "KeyType": "RANGE" }
        ],
        "Projection": { "ProjectionType": "ALL" },
        "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 }
      }
    }]'

$ aws dynamodb update-table \
    --table-name ProductCatalog \
    --attribute-definitions \
      AttributeName=ProductCategory,AttributeType=S \
      AttributeName=Title,AttributeType=S \
      AttributeName=BicycleType,AttributeType=S \
      AttributeName=Price,AttributeType=N \
    --global-secondary-index-updates '[{
      "Create": {
        "IndexName": "BicycleType-Price-index",
        "KeySchema": [
          { "AttributeName": "BicycleType", "KeyType": "HASH" },
          { "AttributeName": "Price", "KeyType": "RANGE" }
        ],
        "Projection": { "ProjectionType": "ALL" },
        "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 }
      }
    }]'

さて、これでインデックスが定義できました。これを見て気づくのは「GSIには、テーブル自体のProvisionedThroughputとは別に、それぞれのProvisionedThroughputを指定している」ということです。テーブルに対する問い合わせと、各インデックスに対する問い合わせ、それぞれに対するスループットを個別に設定・計測していることがわかります。

少々細かい話になりますが。ScanやQueryという操作は、テーブルや各インデックスに対して行います。なので、テーブルや各インデックスのReadCapacityUnitsが消費されていきます。一方、PutやUpdateという操作はテーブルをはじめ複数のインデックスに同時に書き込みを行うため、全てのWriteCapacityUnitsを消費します。ProvisionedThroughputを超えたリクエストはエラーとなるわけですが、書き込み時は、関係する全てのインデックスのWriteCapacityUnitsが足りている必要があります。1つでもProvisionedThroughputが足りない場合は、エラーとなります。

詳細は公開されていないので分かりませんが、要するにGSIというのは、裏では別途新しいDynamoDBテーブルを作成し、整合性が取れるように更新を行ってくれる仕組みなのかもしれませんね。イメージとしてはそういう理解をしています。

各種Queryを実行してみる

テーブルに対するQuery / GetItem

まずは、GSIではなく、普通にテーブルに対するQuery。ハッシュキーであるId103であるアイテムを取得してみます。

$ aws dynamodb query \
  --table-name ProductCatalog \
  --key-condition-expression "Id = :id" \
  --expression-attribute-values '{":id":{"N":"103"}}'

{
    "Count": 1,
    "Items": [
        {
            "ISBN": {
                "S": "333-3333333333"
            },
(略)

このテーブルはハッシュキーテーブルなので、Queryの意味はあんまり無いかもしれません。下記のような、1件取得のGetItemと結果は同じです。

$ aws dynamodb get-item --table-name ProductCatalog --key '{"Id":{"N":"103"}}'

{
    "Item": {
        "ISBN": {
            "S": "333-3333333333"
        },
(略)

ProductCategory-Title-indexに対するQuery

次に、GSI「ProductCategory-Title-index」(ProductCategoryがハッシュキー、Titleがレンジキー)に対して「ProductCategoryBicycleで、Title18-から始まる」アイテムを検索してみましょう。

さらに、--projection-expression を指定して、結果として返して欲しいattributeを限定して、コンパクトなレスポンスにしてみましょう。(このオプションを指定しないと、全attributeが返ります。)

$ aws dynamodb query \
  --table-name ProductCatalog \
  --index-name ProductCategory-Title-index \
  --key-condition-expression "ProductCategory = :category AND begins_with(Title, :title)" \
  --expression-attribute-values '{":category":{"S":"Bicycle"},":title":{"S":"18-"}}' \
  --projection-expression 'Id, Title, Brand, BicycleType, Description'

{
    "Count": 2,
    "Items": [
        {
            "BicycleType": {
                "S": "Road"
            },
            "Description": {
                "S": "201 Description"
            },
            "Brand": {
                "S": "Mountain A"
            },
            "Id": {
                "N": "201"
            },
            "Title": {
                "S": "18-Bike-201"
            }
        },
        {
            "BicycleType": {
                "S": "Mountain"
            },
            "Description": {
                "S": "204 Description"
            },
            "Brand": {
                "S": "Brand-Company B"
            },
            "Id": {
                "N": "204"
            },
            "Title": {
                "S": "18-Bike-204"
            }
        }
    ],
    "ScannedCount": 2,
    "ConsumedCapacity": null
}

BicycleType-Price-indexに対するQuery

続いて、GSI「BicycleType-Price-index」(BicycleTypeがハッシュキー、Priceがレンジキー)に対して「BicycleTypeRoadで、Price200以下の」アイテムを検索してみましょう。

$ aws dynamodb query \
  --table-name ProductCatalog \
  --index-name BicycleType-Price-index \
  --key-condition-expression "BicycleType = :type AND Price <= :price" \
  --expression-attribute-values '{":type":{"S":"Road"},":price":{"N":"200"}}' \
  --projection-expression 'Id, Title, Brand, Price, Description'

{
    "Count": 2,
    "Items": [
        {
            "Description": {
                "S": "201 Description"
            },
            "Price": {
                "N": "100"
            },
            "Id": {
                "N": "201"
            },
            "Brand": {
                "S": "Mountain A"
            },
            "Title": {
                "S": "18-Bike-201"
            }
        },
        {
            "Description": {
                "S": "202 Description"
            },
            "Price": {
                "N": "200"
            },
            "Id": {
                "N": "202"
            },
            "Brand": {
                "S": "Brand-Company A"
            },
            "Title": {
                "S": "21-Bike-202"
            }
        }
    ],
    "ScannedCount": 2,
    "ConsumedCapacity": null
}

BicycleType-Price-indexに対するScan

最後に。先ほど、このインデックスにはBicycleTypeattributeを持たないアイテムは含まれません。つまり、サンプルデータは全8件ですが、「インデックスに対してScan(全件取得)操作をしても、5件のアイテムしか返ってこない」ということです。ということを書きました。それを検証して終わりとしましょうー。

$ aws dynamodb scan --table-name ProductCatalog | jq '.Count'
8

$ aws dynamodb scan --table-name ProductCatalog --index-name BicycleType-Price-index | jq '.Count'
5

さらっとCountしか見ませんが。全件は8件なのに、このインデックスには5件しか入っていませんね。jqを通さなかった結果は、各自確認してみてください。

まとめ(各インデックス比較表)

テーブル(暗黙的インデックス) LSI GSI
定義できるテーブル 出来るとか出来ないとかじゃなくて必須 複合キーテーブルのみ 任意
テーブル毎に定義できる数 必ず1個 0〜5個 0〜5個
サイズ上限 なし 10GB なし
含むattribute 全て 各キー + 指定したもの(全ても可) 各キー + 指定したもの(全ても可)
作成タイミング テーブル作成時に作成後、変更不可 テーブル作成時に作成後、変更不可 任意のタイミングで追加削除可能

他にも比較すべきポイントはまだあるかもしれませんが、とりあえず気づいたところまで。随時追記するかもしれません。

脚注

  1. キーとインデックスの違いはこちらで復習してください。