😕

DynamoDBを初めて触ってみた所感

に公開

DynamoDBを初めて触ってみた所感

DynamoDBは、AWSのマネージドNoSQLデータベースです。
リレーショナルデータベース(RDBMS)と違ってスキーマレスデータベースであるため、柔軟にデータの設計が行えます。

そんなDynamoDBを初めて触ってみて、どのような問題にぶつかったのか、どのような内容を習得したのか、その所感をまとめてみました。

前提条件

本記事では、Amazonが提供しているAWS SDKを使って、DynamoDBのデータを操作するJavaのサンプルコードを掲載しています。
前提としているJavaとAmazon SDKのバージョンは以下のとおりです。

  • Java 21
  • AWS SDK for Java 2.0

パーティションキーとソートキー

DynamoDBには、パーティションキーとソートキーという要素があり、これらのキーの属性をテーブルに必ず定義する必要があります。
最初はこれら2つのキーの理解からのスタートでした。
そして、リレーショナルデータベースと同様に、データを一意的に識別するためのプライマリーキーが必要となります。
DynamoDBでは、2つの方法のどちらかでプライマリーキーを定義します。

  1. パーティションキーのみ
  2. パーティションキーとソートキー

1. パーティションキーのみ

1つの項目で一意的に識別できるID(UUIDなど)をプライマリーキーとして定義する場合、パーティションキーのみで十分です。

以下のコードは、Bookテーブルを例にした、パーティションキーのみを条件にするデータ取得のサンプルです。

属性 キー
1 id パーティションキー ブックID
2 category ソートキー カテゴリ名
3 name - 書籍名
4 author - 著者
    public Optional<Book> findByPrimaryKey(String category, String name) {
        Map<String, AttributeValue> eav = new HashMap<String, AttributeValue>();
        eav.put(":id", new AttributeValue().withS(category));
        DynamoDBQueryExpression<Book> queryExpression = new DynamoDBQueryExpression<Book>()
                .withKeyConditionExpression("id = :id")
                .withExpressionAttributeValues(eav);
        PaginatedQueryList<Book> result = db.query(Book.class, expression);
        return !result.isEmpty() ? Optional.of(result.getFirst()) : Optional.empty();
    }

2. パーティションキーとソートキー

リレーショナルデータベースで言う複合キーに相当します。
例えば、パーティションキーに「種類」、ソートキーに「名前」のように、2つを属性を組み合わせてプライマリーキーを定義します。

先ほどのBookテーブルを以下のように変更し、パーティションキーとソートキーを使ったデータ取得のコードはこのようになります。

属性 キー
1 category パーティションキー カテゴリ名
2 name ソートキー 書籍名
3 author - 著者
    public Optional<Book> findByPrimaryKey(String category, String name) {
        Map<String, AttributeValue> eav = new HashMap<String, AttributeValue>();
        eav.put(":category", new AttributeValue().withS(category));
        eav.put(":name", new AttributeValue().withS(name));
        DynamoDBQueryExpression<Book> queryExpression = new DynamoDBQueryExpression<Book>()
                .withKeyConditionExpression("category = :category and name = :name")
                .withExpressionAttributeValues(eav);
        PaginatedQueryList<Book> result = db.query(Book.class, expression);
        return !result.isEmpty() ? Optional.of(result.getFirst()) : Optional.empty();
    }

パーティションキーのみのサンプルコードと比較すると、ソートキーが検索条件に追加されただけでそれ以外は特に違いがありません。

テーブルの設計

パーティションキーやソートキーを使ったデータの基本的な取得方法を説明しました。
もちろん、パーティションキーやソートキーでない属性についても自由にフィルタリングして、データを検索することができます。
パーティションキーやソートキーでない属性を検索条件にしたクエリーを実行してしまうと、DynamoDBはテーブルの全レコードをフルスキャンしてしまう問題があり、処理コストが高くなることを初めて知りました。
可能な限り、パーティションキーとソートキーの属性だけでデータを検索できるようにテーブルを設計することが重要です。
そのため、パーティションキーとソートキーの属性と、その属性にどのような値を設定するか、これがテーブル設計のポイントです。
1つの方法として、ソートキーの属性の値に、クエリーの条件で指定する値を設定します。
クエリーの検索条件が複数ある場合は、"#"などで値を連結した文字列を設定することによって、検索条件の値を指定しやすいようにします。

例えば、先ほどのBookテーブルを例に以下のように定義を変更すると、カテゴリ名や著者をソートキーで検索できるようになります。

属性 キー
1 id パーティションキー ブックID
2 sort_key ソートキー カテゴリ名 + "#" + 著者
3 category - カテゴリ名
4 name - 書籍名
5 author - 著者

インデックス

テーブル設計を行う際、パーティションキーとソートキーの属性だけでデータを検索できるようにすることが重要であると説明しました。
とはいっても、テーブルに定義したパーティションキーとソートキー以外の属性でデータを検索したいケースは当然あります。
DynamoDBには、グローバルセカンダリインデックス(GSI)と呼ばれるインデックスがあります。
これを作成することにより、テーブルに定義したパーティションキーとソートキー以外の属性をパーティションキーとソートキーに指定することができます。
グローバルセカンダリインデックスは最大20個まで作成できますが、インデックスをたくさん作成しすぎると、データの書き込み時に処理コストが増えます。
この点はリレーショナルデータベースのインデックスと同様です。
またもう一つの注意点として、DynamoDBのグローバルセカンダリーインデックスはデータの書き込み時に料金も発生します。

データの保存と削除

テーブルにデータを保存するにはDynamoDBMapper#save()を使用します。
このAPIは、SQLでいうとUPSERT(INSERTUPDATEを組み合わせた書き込み)と同等の振る舞いで実行します。
つまり、プライマリーキーが一致するデータが存在しない場合は追加し、プライマリーキーが一致するデータが存在する場合は更新します。
新規データの追加でしたら特に問題ありませんが、既存データの更新ではプライマリーキー以外のすべての属性を更新します。

そのため、既存データの更新ではDynamoDBMapperConfig.SaveBehavior.UPDATE_SKIP_NULL_ATTRIBUTES.config()save()の第2引数に渡します。
こうすることで、変更前と異なっている属性のみを更新します。

そして、テーブルにあるデータを削除するにはDynamoDBMapper#delete()を使用します。

以上をまとめると、DynamoDBMapperを使ってデータの登録(create)、更新(update)、削除(delete)を実装したコードは次のようになります。

    public void create(Item item) {
        db.save(item);
    }

    public void update(Item item) {
        db.save(item, DynamoDBMapperConfig.SaveBehavior.UPDATE_SKIP_NULL_ATTRIBUTES.config());
    }

    public void delete(Item item) {
        db.delete(item);
    }

自由にページネーションできない

データの一覧を表示する画面には、「前へ」「次へ」「表示件数」などのUIを配置したページネーション機能をよく見かけます。
リレーショナルデータベースでは、以下のようにOFFSET(開始位置)やLIMIT(抽出件数)を使ったSQLを作成することで、ページネーションで表示するデータの取得処理を実装することができます。

SELECT カラム名, ... FROM テーブル名 OFFSET 100 LIMIT 10;

しかし、DynamoDBでは、LIMITを指定することはできますが、OFFSETを数値で指定する機能まで提供されていませんでした。
こうした制約の中で、いろいろ調べながらページネーションの実装を試みましたが、結局断念しました。

1MBの制限

DynamoDBでは、1回のクエリーで取得できるデータ量が1MBまでという制限があります。
この制限を上回るデータを取得する場合は、クエリー結果から取得できるLastEvaluatedKeyを使って、同じクエリーを繰り返し実行する必要があります。

例えば、以下のソースコードは、LastEvaluatedKeyを使って、検索条件(queryExpression)に合致するデータを全て取得するメソッドのサンプルです。

    public List<Item> query(DynamoDBQueryExpression<Item> queryExpression) {
        List<Item> items = new ArrayList<>();

        do {
            QueryResultPage<Item> result = db.queryPage(Item.class, queryExpression);
            items.addAll(result.getResults());

            queryRequest.setExclusiveStartKey(result.getLastEvaluatedKey());
        } while (result.getLastEvaluatedKey() != null);

        return items;
    }

LastEvaluatedKeyを使ったループ処理を実装することで、1MBの制限を超えるクエリー結果を取得することができます。
その反面、検索条件に一致するデータの規模によって、処理コストが高くなる可能性があります。

COUNTの問題

リレーショナルデータベースのSELECT COUNT(*)に相当するクエリーがDynamoDBのAPIに用意されています。
DynamoDBMapper#count()メソッドを使うと、クエリー結果の件数を取得できます。
ところが、このcount()メソッドは、検索条件に一致するクエリー結果を繰り返し取得していきながら件数をカウントするように実装されているため、クエリー結果の規模が大きくなればなるほど、クエリーの処理コストが高くなっていきます。
これは、前述の「1MBの制限」で触れた内容とも関連しています。

(参考) DynamoDBMapper#count() ソースコード

    public <T> int count(Class<T> clazz, DynamoDBQueryExpression<T> queryExpression,
            DynamoDBMapperConfig config) {
        config = mergeConfig(config);

        QueryRequest queryRequest = createQueryRequestFromExpression(clazz, queryExpression, config);
        queryRequest.setSelect(Select.COUNT);

        // Count queries can also be truncated for large datasets
        int count = 0;
        QueryResult queryResult = null;
        do {
            queryResult = db.query(applyUserAgent(queryRequest));
            count += queryResult.getCount();
            queryRequest.setExclusiveStartKey(queryResult.getLastEvaluatedKey());
        } while (queryResult.getLastEvaluatedKey() != null);

        return count;
    }

この問題の解決策の1つとして、検索条件に一致するデータの件数をどこかにキャッシュする方法があります。
例えば、DynamoDBにCOUNT専用のテーブルを作成して、検索条件に合致するデータの件数をキャッシュすることで、件数を求める処理コストを常に一定に保つことができます。
もちろん、データの整合性を保たないといけないため、COUNT専用のテーブルを更新するタイミングを考慮して、アプリケーションを実装する必要があります。

まとめ

時間をかけて調べていく中で、DynamoDBの使い方やコツをいろいろ発見することができました。
DynamoDBの機能や制約をもとに、データの設計や操作をしないといけないため、 リレーショナルデータベースの感覚で使ってはいけないということを痛感できました。

株式会社キャリオット

Discussion