🧑‍💻

DynamoDBで使っているテクニック

2024/01/21に公開

はじめに

卒業アルバムで生徒たちの顔写真を均等に掲載するように写真を選ぶAlbumieというAIサービスを個人開発しています。

https://albumie.app/

このサービスでは、DynamoDBを使用し、

  • シングルテーブル設計
  • 更新が多い属性を別レコードに分割
  • データの圧縮

といったDyanamoDBのテクニックを利用しています。
この記事では、これらのテクニックをどのようにサービスで利用しているかを紹介します。

なお、記事に記載したコード例はJavaScriptでAWS SDK v3を使用しています。

シングルテーブル設計

DynamodDBでは効率的なクエリを行うためにデータをひとつのテーブルに格納するシングルテーブル設計という設計手法があります。
リレーショナルデータベースのように複数のテーブルを利用する設計はマルチテーブル設計と呼ばれ、どういったときにどちらの手法を使うとよいかは Amazon DynamoDB におけるシングルテーブル vs マルチテーブル設計 が詳しいです。

Albumieではシングルテーブル設計を採用しており、ユーザが所属するグループ情報やグループが保持する写真の管理単位であるコレクション情報など、さまざまな情報を以下のように1つのテーブルに格納しています。

PK(Partition Key) SK(Sort Key) name その他の属性
Group-1 Group テストグループ ...
Group-1 Collection-1 テストコレクション1 ...
Group-1 Collection-2 テストコレクション2 ...

サービスでは、グループ情報とそのグループのコレクション一覧を取得するAPIがあります。
グループ情報とコレクション情報を別々のテーブルに格納している場合、それぞれのテーブルへのクエリが必要です。しかし、シングルテーブル設計によって1回のクエリで取得できます。
以下は、グループ情報とコレクション一覧を1回のクエリで取得しているコード例です。

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb";

// データの取得
const client = DynamoDBDocument.from(new DynamoDBClient());
const result = await client.query({
  TableName: "TestTable",
  KeyConditionExpression: 'PK = :groupId',
  ExpressionAttributeValues: {
    ':groupId': 'Group-1',
  }
});

let group = {}
const collections = [];

// グループ情報とコレクション情報の仕分け
for(const d of result.Items) {
  if (d.SK === "Group") {
    group = {
      id: d.PK,
      name: d.name
    }
  } else if (d.SK.startsWith === "Collection-") {
    collections.push({
      id: d.SK,
      name: d.name
    });
  } else {
     throw new Error(`unexpected type: ${d.SK}`);
  }
};

更新が多い属性を別レコードに分割

サービスではアップロードされた写真枚数を管理しており、写真がアップロードされるたびに既存の写真枚数をインクリメントして保存しています。
この値は、単にグループのレコードの属性値として保持するのではなく、以下のとおりグループとは別のレコードに保存しています。

PK(Partition Key) SK(Sort Key) name count
Group-1 Group テストグループ
Group-1 PhotoCount 1000

これはDynamoDBの書き込み(更新)にかかるキャパシティユニットが、操作するレコードのサイズやインデックス(GSI)によって変わるためです。
DynamoDBでは1KB単位で1キャパシティユニット(端数切り上げ)が消費されます。
例えば、1.1KBのデータの書き込みは2キャパシティユニット消費します。
また、ひとつのGSIにつき、消費されるキャパシティユニットは倍になります。
データのサイズ、GSIと消費するキャパシティユニットの関係を式に表すと以下のとおりです。

消費するキャパシティユニット数 = ceil(レコードサイズ / 1KB) * (1 + GSI数)

レコードの更新において、消費するキャパシティユニットは、更新する値のサイズではなく、更新しようとする値が格納されているレコードのサイズとGSIによって上記の計算式のとおり決まります。
例えば、サイズが1.1KB で、GSIを1つ設定しているレコードのある属性値のみを更新しても、それに消費されるキャパシティユニットは 4 キャパシティユニットになります。

4キャパシティユニット = ceil(1.1KB / 1KB) * (1 + 1)

そのため、更新対象のレコードサイズが1KBを超える場合やGSIを設定していて更新が頻繁にある場合は、更新する属性値を別レコードにすることでキャパシティユニットの消費を抑えることができます。
別レコードにしていても、それぞれのレコードをクエリするのではなく、「シングルテーブル設計」で記したようなクエリを使用することで1回のクエリでまとめて取得できます。

データを圧縮する

「更新が多い属性を別レコードに分割」に書いたとおり、DynamoDBでは書き込むサイズに応じて消費するキャパシティユニットが変わります。
そのため、大きなサイズの属性については、GZIPなどで圧縮して保存することで消費するキャパシティユニットを削減できます。
以下のコードは、GZIPで圧縮してデータを保存する例です。

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb";
import { gzipSync } from "zlib";

const client = DynamoDBDocument.from(new DynamoDBClient());
const binary = gzipSync(
  JSON.stringify({ title,  body })
);
await client.put({
  TableName: "TestTable",
  Item: {
    PK,
    SK,
    binary
  }
});

属性名もレコードのサイズに含まれるので、1つのレコードのサイズを最小限に抑えたい場合は、属性名の短縮も検討するとよいでしょう。

おわりに

サービスで使っているDynamoDBの以下のテクニックを紹介しました。

  • シングルテーブル設計
  • 更新が多い属性を別レコードに分割
  • データの圧縮

これらのテクニックを利用することで、コストを最小限に抑えつつ高いパフォーマンスのサービスを提供しています。

Discussion