Amazon Web Services ブログ

Amazon DynamoDB からのクエリで実証的にテストと測定を行う



この投稿では、Amazon DynamoDB のクエリを実証分析するために、弊社チームがどのようにしてシンプルな出力を安価に提供できたのかについて説明します。私たちの目標は、DynamoDB のレガシーデータベースを使用し、データを新しい方法で変換してから、新しい DynamoDB データベースに格納することでした。このプロジェクトに 8 か月間作業を重ねてきた結果、システムについての理論的な推論をやめ、代わりに実証的にテストと測定を行うことにしました。

レガシーユースケースが正しいことを数え切れないほど証明しましたが、システムのパフォーマンスが高くつく割に遅かったため、最終的にレガシーユースケースは受け入れられませんでした。やるべきタスクに、多くのレガシーサービスが残ってしまいました。私たちはコードの解読や多数のレガシーサービスを介したデータの追跡などで忙しく、疲れ果てていたので、別の方法でプロジェクトにアプローチしようとしたのです。

実証的に考える

コードを書き、実行し、望ましい結果が出るまでそれを繰り返すという開発サイクルに慣れています。間違った場合は、コードに戻り、記憶の中にデータを取り込み。実行を追跡します。特に行き詰まってしまった場合には、println ステートメントを追加してシステムの状態を追跡します。

こうした理論的アプローチは、プロジェクトが小規模であれば管理可能です。しかし大規模になると、複数のシステムにわたるデータを推論することはほぼ不可能です。そのため、大規模なケースでは実証的な理論は放棄した方がよいのです。

私たちのチームは RequestMetricCollector を使用して安価でシンプルなコードを書き、標準化した DynamoDB クエリをログ記録しました。クエリを標準化するとは、クエリの形式を一意にするためのデータを含まない文字列として作成することを意味します。つまりフィールド、テーブル名、インデックス名だけが残るまですべてのデータを削除するのです。

RequestMetricCollector アクションを使用して、AWS SDK で AWS への呼び出しを傍受できます。このアプローチは他の AWS のサービスにも同様に機能するはずです。汎用リクエストと応答オブジェクトを持つ collectMetrics 関数が公開されています。ドキュメントはメトリクスの可能性を優先しますが、メトリクス以外のデータも同様に利用可能です。他のサービスやシステムと統合する必要がないため、低コストです。

すべての作業はメモリ内で行われます。出力は人間が読むことができる標準化したクエリのリストであるため、自然です。分析用にクエリデータを別のデータストアに配置する必要はありません。

以下の図では、まず最初に複雑なアーキテクチャが示されており、これらは本質的に推論が難しい数多くのサービスで構成されています。コンポーネントの 1 つが DynamoDB を呼び出し、データを永続化します。RequestMetricsCollector でこれらの呼び出しを傍受し、各リクエストに関する情報を記録します。これは、次のセクションにある問題のいくつかを解決するのに役立ちます。

ALT テキスト: サービス 1 がサービス 2、3、4 などとやり取りする方法、およびそれらのサービスが DynamoDB テーブルと接続するサービス N とやり取りする方法を示すワークフロー図。DynamoDB テーブルが AWS SDK (RequestMetricsCollector) とやり取りした後、AWS SDK がホスト/サーバーにログオンします。

複雑なアーキテクチャ:

図のインデックス

  1. 多数の相互接続サービスを含む複雑なアーキテクチャ
  2. collectMetrics (リクエスト、応答) の実装
  3. ホスト/サーバーへログオンする。例:
    PerformanceMetrics: @@ 4 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}

実証的テストの一般的なユースケース

実証的にテストを行うと、次のような問題を解決するのに役立ちます。

  • パフォーマンス分析: 1 種類のクエリ用に DynamoDB テーブルを調整するということは、他の種類のクエリがそれほど効率的ではなくなることを意味します。パフォーマンスのトレードオフを考慮しなければなりません。パフォーマンスが悪いのはどうしてですか? 特定のクエリパラメータが、クエリの実行速度が予想より遅くさせるのはなぜしょう? 実証的アプローチでは、こうした疑問に答えるのを助けてくれます。
  • DynamoDB データベース上のレガシーシステムのリファクタリング: 「レガシー」とは、エンジニアが慣れていないシステムであることが普通です。自身のコードをデバッグするのは難しいですが、他の何人かのエンジニアが書いたコードをデバッグする方がはるかに困難です。元のリクエストからの DynamoDB を逆方向に読み書きするのは骨の折れる作業です。弊社のプロジェクトでは、システムへのレガシー入力を DynamoDB レベルで行われている読み書きに正常に関連付けることができませんでした。実証的アプローチはより小さな問題空間 (理論よりもはるかに小さい) での処理を助けるだけでなく、問題の正確な姿を示してくれたのです。
  • データ関連バグの根本原因の分析: バグの根本的な原因を探していると、ログに不十分なまたは間違ったクエリがあると表示されることがよくあります。そうすると解決策が突如として明らかとなり、バグを素早く修正することができます。以下のサンプルコードのセクションでは、DynamoDB レイヤーの標準化したクエリとサンプルクエリを使用してログを生成し、バグの根本的な原因を見つけるサポートを行います。これらのコードがあれば、コードを読んで憶測するという必要はなくなります。このアプローチのもう 1 つの利点は、コードを一時的に挿入しても、影響を与えることなくすばやく削除できることです。

サンプルコード

このセクションでは、DynamoDB リクエストメタデータを迅速に記録するための 3 つの Java クラスについて説明しています。DDBQueryLogger.java と DDBQueryInfo.java の 2 つのクラスを、コードに統合します。3 つ目のクラスは、Java で書かれた AWS Lambda 関数の例です。このテストを自分の AWS アカウントに再作成する方法についても説明しています。

コードベースでクラスを作成した後は、どのように接続していますか? DynamoDB クライアントを構築 (または設定) するコードを見つけて、そのコードまたは設定に DDBQueryLogger メトリクスコレクターを追加します。LogTest.java クラスは、「withMetricsCollector()」関数を使用した例を示しています。ログ記録を無効にする準備ができたら、この行を削除できます。

それでは、2 つのクラスがどのように機能するかを考えてみましょう。パフォーマンスと正確さを意図的にトレードオフし、迅速に洞察を得ているはずです。

その一例が、使用している正規表現です。計算コストがかかりますが、作業や理解が簡単です。インメモリデータ構造のスレッドセーフのような他の問題は、「ベストエフォート」アプローチを使用して迅速に洞察を生み出します。

次の 2 つのクラスでは、以下の手順に従います。

  • システムから DynamoDB へのあらゆるリクエストをログに記録する。
  • クエリのテキスト表現を標準化する。
  • 結果をメモリ内データ構造に追加する。
  • 5 分毎にインメモリデータ構造をログにフラッシュする。

オフラインで出力をさらに分析することができます。

DDBQueryLogger.java:

package example;

import com.amazonaws.Request;
import com.amazonaws.Response;
import com.amazonaws.metrics.RequestMetricCollector;

import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;

public class DDBQueryLogger extends RequestMetricCollector {

    private static final long TIMER_DELAY = 0L;
    private static final long TIMER_PERIOD = 100; // 5 * 60 * 1000;
    private static final int MAX_HASH_MAP_SIZE = 10000;
    protected static final ConcurrentHashMap<String, DDBQueryInfo> ddbQueryInfos = new ConcurrentHashMap<>();

    // These regular expressions strip out all query specific values.This helps
    // us “standardize” the textual representation of the query.For example,
    // “uuid={S:123}” would become “uuid={S:}”.This makes it possible to group
    // classes of queries together.
    private static final Pattern N = Pattern.compile("(?<=N:)(.*?)(?=,)");
    private static final Pattern S = Pattern.compile("(?<=S:)(.*?)(?=,)");

    private static final TimerTask emitLogTask = new TimerTask() {
        /**
         * Loop over all standardized DDB queries in memory for this host and emit our DDBQueryInfo object.
         */
        public void run() {
            for (final DDBQueryInfo ddbQueryInfo : ddbQueryInfos.values()) {
                System.out.println("PerformanceMetrics: " + ddbQueryInfo.toString());
            }
        }
    };

    private static final Timer timer = new Timer("Timer");

    static {
        timer.scheduleAtFixedRate(emitLogTask, TIMER_DELAY, TIMER_PERIOD);
        System.out.println("Timer started.");
    }

    @Override
    public void collectMetrics(Request<?> request, Response<?> response) {
        if (request == null || request.getOriginalRequest() == null) {
            return;
        }

        // Preventing out-of-memory errors.If you are standardizing queries correctly, you should never come close to this.
        if (ddbQueryInfos.keySet().size() > MAX_HASH_MAP_SIZE) {
            System.out.println("DDBQueryLogger in-memory data structure is too big.Standardized queries are broken.");
            return;
        }

        final String ddbQuery = standardizeQuery(request.getOriginalRequest().toString());

        final DDBQueryInfo infoOld = ddbQueryInfos.get(ddbQuery);

        final DDBQueryInfo infoNew = new DDBQueryInfo();
        infoNew.setExampleQuery(request.getOriginalRequest().toString());
        infoNew.setStandardizedQuery(ddbQuery);

        if (infoOld != null) {
            infoNew.getCount().set(infoOld.getCount().incrementAndGet());
        } else {
            infoNew.getCount().set(1L);
        }

        ddbQueryInfos.put(ddbQuery, infoNew);
    }

    /**
     * Take a DDB query (as a string) and remove all values that make it unique.
     * @param query toString() of the "original request" from the AWS SDK.
     * @return Standardized DDB query.
     */
    private String standardizeQuery(final String query) {
        return N.matcher(S.matcher(query).replaceAll("")).replaceAll("");
    }

}

DDBQueryInfo.java:

package example;

import java.util.concurrent.atomic.AtomicLong;

public class DDBQueryInfo {
    private AtomicLong count;
    private String exampleQuery;
    private String standardizedQuery;

    public AtomicLong getCount() { return count; }
    public void setCount(AtomicLong count) { this.count = count; }

    public String getExampleQuery() { return exampleQuery; }
    public void setExampleQuery(String exampleQuery) { this.exampleQuery = exampleQuery; }

    public String getStandardizedQuery() { return standardizedQuery; }
    public void setStandardizedQuery(String standardizedQuery) { this. standardizedQuery = standardizedQuery; }

    public DDBQueryInfo() {
        this.count = new AtomicLong();
    }

    /**
     * "@@" should help us tokenize a log entry.
     * @return A parsable string representing the object state.
     */
    @Override
    public String toString() {
        return String.format("@@ %s @@ %s @@ %s", count, exampleQuery, standardizedQuery);
    }
}

LogTest.java:

package example;

import com.amazonaws.AmazonServiceException;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.model.AttributeValue;
import com.amazonaws.services.dynamodbv2.model.GetItemRequest;
import com.amazonaws.services.lambda.runtime.Context; 
import com.amazonaws.services.lambda.runtime.RequestHandler;

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class LogTest implements RequestHandler<Integer, String> {

    public String handleRequest(Integer myCount, Context context) {
    	for (int i = 0; i < 5; i++) {
    		getItem();
    	}

        return "Log test done.";
    }

    public void getItem() {
        HashMap<String, AttributeValue> key = new HashMap<>();

        key.put("uuid", new AttributeValue("73bed7db-55d8-4ecc-b516-ddb0672bc31a"));

        GetItemRequest request = new GetItemRequest()
            .withKey(key)
            .withTableName("LogTest");

        // Make sure that your DynamoDB client references the DDBQueryLogger class.
        // You might consider turning it on/off with a feature flag.
        final AmazonDynamoDB ddb = AmazonDynamoDBClientBuilder
        	.standard()
        	.withMetricsCollector(new DDBQueryLogger())
        	.build();

        try {
            Map<String, AttributeValue> item = ddb.getItem(request).getItem();
        } catch (AmazonServiceException e) {
            System.err.println(e.getErrorMessage());
        }
    }

}

Lambda 関数を使ったテスト

Gradle を使用して、この記事の 3 つのクラスを LogTest という DynamoDB テーブルに対して実行するようにパッケージ化しました。テーブルには uuid という名前のフィールドが 1 つあり、サンプルコードの値をハードコードしています。Gradle を使用して Java で記述された Lambda 関数をパッケージ化する方法については、「Java 関数の ZIP デプロイパッケージの作成」をご参照ください。

こちらが使用した build.gradle ファイルです。

apply plugin: 'java'

repositories {
  mavenCentral()
}

dependencies {
    compile (
        'com.amazonaws:aws-lambda-java-core:1.1.0',
        'com.amazonaws:aws-lambda-java-events:1.1.0',
        'com.fasterxml.jackson.core:jackson-core:2.9.8',
        'com.amazonaws:aws-java-sdk-dynamodb'
    )
}

task buildZip(type: Zip) {
    from compileJava
    from processResources              
    into('lib') {
        from configurations.compileClasspath
    }           
}

build.dependsOn buildZip

コードの説明

コードの使命は、既存のシステム (DynamoDB クライアントオブジェクトへの 1 行の変更) と統合し、測定を実行し、簡単に抽出することです。テスト中のシステムに新しい依存関係を導入したくはありません。設計の際に決定した事項は、次のとおりです。

  • すべての DynamoDB クエリをメモリに保存する。無限に増大するのを防ぐためのチェックがあります。(DDBQueryLogger.java、47–50 行目)。
  • DynamoDB へのクエリを標準化する。これにより、メモリフットプリントを小さくするクエリのクラスをグループ化することができ、分析しやすくなります。(DDBQueryLogger.java、60–64 行目)。
  • データ構造を定期的にログにフラッシュする。データ収集はもう少し面倒になりますが、依存関係の追加が必要なくなり、複雑にならないようにします。
  • 標準化した各クエリの例を残しておく。データを再利用し、特定のユースケースで掘り下げることができるかもしれません。

出力を理解する

DDBQueryInfo クラスの出力は任意です。すぐにログを扱えるという、私の個人的な好みが反映されています。ご自身のニーズに合わせて出力を変更することをお勧めします (DDBQueryInfo.java、29 行目)。

こちらは LogTest Lambda 関数を実行した例です。

START RequestId: 5e22204c-d91c-4899-924d-abaef69589f4 Version: $LATEST
Timer started.
PerformanceMetrics: @@ 1 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
PerformanceMetrics: @@ 1 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
PerformanceMetrics: @@ 1 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
PerformanceMetrics: @@ 1 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
PerformanceMetrics: @@ 2 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
PerformanceMetrics: @@ 2 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
PerformanceMetrics: @@ 4 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
PerformanceMetrics: @@ 4 @@ {TableName: LogTest,Key: {uuid={S: 73bed7db-55d8-4ecc-b516-ddb0672bc31a,}},} @@ {TableName: LogTest,Key: {uuid={S:,}},}
END RequestId: 5e22204c-d91c-4899-924d-abaef69589f4
REPORT RequestId: 5e22204c-d91c-4899-924d-abaef69589f4	Duration: 9607.21 ms	Billed Duration: 9700 ms 	Memory Size: 512 MB	Max Memory Used: 139 MB	

PerformanceMetrics: プレフィックスを無視して @@ 文字列を区切り文字として使用すると、このレコードには 3 つのフィールドがあることがわかります。

  • DynamoDB クエリの標準化バージョンを見た回数。
  • クエリの一意の値が削除された、標準化したクエリ文字列の例。コードを介してデータをトレースするときに、これが役に立つことがわかりました。
  • 標準化したクエリ。

これらのフィールドを使うと、コードが DynamoDB を呼び出している方法について理解することができます。どの種類のインデックスをテーブルに追加するのか、またはなぜ特定のユースケースの実行速度が期待するほど早くならないのかを検討するのに特に役立ちます。

この記事にあるコードは、遅いクエリの原因を直接的に突き止めようとするものではありません。52 行目で DDBQueryLogger.java を変更して、IOP 消費量などの他のデータを含めることを検討してください。

DynamoDB へのクエリリクエストのみがログに記録されますが、「ホット」クエリまたは頻繁に呼び出されるクエリ内のどのフィールドを、インデックス付きフィールドに移動する方がよいのかを確認できます。

結論

この実証的アプローチは、DynamoDB クエリを分析するのに便利です。この記事のコードは、ご自身の特定の問題やユースケースに利用できるはずです。

たとえば、消費した容量をより詳細なレベルで追跡するようにコードを拡張することができます。出力フォーマット (DDBQueryInfo.java、29行目) をより使いやすい形式に変更することもできます。この投稿が問題解決の手助けとなることを願っています。DynamoDB を使い続ければ、あなたの顧客にご満足していただけることでしょう。

 


著者について

 

Ryan Meyer は Amazon Digital Goods のシニアエンジニアです。