DynamoDBデータモデリング虎の巻:第参巻 〜実践編〜
前巻のおさらい
前巻はDynamoDBのデータモデリングをするにあたって必要な考え方について、RDBとの対比を交えながら触れてきました。
今回は
この巻では、実際にどのようにデータモデリングしていけば良いのかといった実践的な内容を、いくつかの汎用的と思われるパターンを例にしつつ書き記していきたいと思います。
たぶん、今までの3巻の中で一番今までの巻を理解していないとピンと来ない内容だと思うので、まずは第壱巻と第弐巻を読むことをオススメします。
Partition Keyの決め方
まずは、DynamoDBでテーブルを作る際に必ず必要な Partition Key
の決め方です。前巻では以下のように書きましたが、じゃあ実際にその識別子はどうやって採番するのか?ということです。
ECサイトなら「ユーザ」「商品」「注文」といった特定の意味のある存在を表す実体です。これはMicroservices的な各サービスが担うべき特定の業務ドメイン領域にも多くの場合に合致します。そして、これら1つの実体は基本的にユーザIDや注文番号、SKUといった識別子を持っているものです。これをPartition Key とするのです。
では、実際にユーザ系の情報を扱うテーブルを例にとって解説していきたいと思います。
一意なIDの決め方
考えられる方法はいくつかありますが、極端に大別すると以下になるかと思います。
- 連番
- UUIDのような順序性の無いランダムな値
この2つでまず採用すべきでないものは「連番」です。DynamoDBで連番カウンターを作ることが厳しいことはカウンターアイテムのPartitionが偏ることを考えれば想像に難くないかと思いますが、カウンターを用いないMAX + 1を取る方法はもっと現実的ではありません。 Partition Key
はHash Tableなデータ構造なので、B+Treeな Sort Key
と違ってフルスキャンすることでしかMAXが取れませんし、MAXした結果が被らないようにロックを取ることもできません。
よって、基本的にUUIDのような衝突する可能性が極小となっているランダム値が良いです。ちなみに「Snowflake」もイマイチです。なぜなら、繰り返しになりますが Partition Key
はHash Tableなデータ構造なのでフルスキャンを避けてソートできないからです。「Snowflake」が活きるかもしれない場としては Sort Key
のSuffixに付けて生成順にしつつデータを分ける時くらいでしょうか。
Twitter IDs (snowflake) — Twitter Developers
ちなみに今回例にしたユーザのIDについては、Serverlessな実装だと認証とユーザのID管理にCognitoを使うケースも多いと思います。その場合はCognitoが採番するUser IDを使いましょう。Cognito User Poolの情報と紐付けられるのみならず、ユーザが自分の対象アイテムだけにアクセスできるようアクセス制御するのも簡単に実装できます。
Partition KeyだけでSort Keyが要らないパターン
今回の例からはズレますが、シンプルにKeyに対するValueだけが引ければ良いような場合は、プライマリキーを Partition Key
のみとしてHash Tableだけでアクセスできるようにした方が高速にアクセスできます(NWレイテンシ等も考えると微々たるものですが)2つの要素の組み合わせで一意となる場合でも、それが常に組み合わせて一意な条件でしか使わないのなら Sort Key
と組み合わせたプライマリキーにするよりも結合した値を Partition Key
としてしまって良いです。後から別の条件で検索したくなったときに対応が困難になるので、完全に明確であるケースだけにはなりますが。
射影としてのGSIについて
実際に例を出して見ていく前に、GSI
の射影について前巻でも触れましたが、もう少し詳しく触れておきたいと思います。
GSI
がテーブルから射影する属性は下記のオプションを選択することができます。
KEYS_ONLY
: テーブルのプライマリキーの値、およびインデックスキーの値のみで構成されるINCLUDE
:KEYS_ONLY
+ 選択した非キー属性のみ含まれるALL
: 全ての属性が含まれる
このうち、どれを選択すべきかは、どのようなデータアクセスが多いかによって決まります。まず、GSI
に含める属性が増えれば増えるほどインデックスの容量と更新コストが増大します。 GSI
の更新は実テーブルの更新に対して自動的に非同期で適用されますが、その際に使用されるキャパシティユニットが増大するということです。もし含めなかったとしても、最低限含まれるプライマリキーによって実テーブルからデータが都度フェッチされるため見かけ上のオペレーションとしての差異はありません。
では、どのような場合にインデックスに含める属性を増やすかというと、そのインデックスに対してクエリを行う際に頻繁に取得する属性がある場合です。インデックスに含まれている属性は実テーブルからデータをフェッチする必要がなくなり、特定のPartition内だけで読み込みが完了するため読み込み効率が上がります。いわゆる、MySQLで言う所の Covering Index
やPostgreSQLで言う所の Index Only Scan
です。そのようなクエリが効果的かつ頻繁に想定される場合、更新コストや容量の効率および課金と天秤にかけて KEYS_ONLY
以外を選択します。
ちなみにここから先は実際のデータを模した二次元表が出てきますが、 GSI
には暗黙的にプライマリキーが含まれていると思って見てください。
Sort Keyの決め方と汎用的なGSI
前巻で書いた「できる限りテーブルを少なくする」というものは、同じように GSI
にも適用されます。 前巻でも述べたように、 GSI
は元となるテーブルに対する射影として実体を持つ別のテーブルと言えるものであり、独立したキャパシティの管理が必要となります。つまり、多ければ多いほど管理コストが増します。そして何より、 GSI
は1テーブルあたり5つまでという制限を超えないよう気を使わなくてはならず、これは「できる限りテーブルを少なくする」という方針と対立します。だから、 GSI
の使い方はとても重要なのです。
Partition Key
はある実体に対する一意な識別子と比較的シンプルに決めることができますが、 Sort Key
についてはその実体に対するアクセスパターンを十分に踏まえる必要があります。とはいえ、ある程度順序立てて決めていくことが可能です。
まず、前巻で述べたようにある実体に対して必要なデータを列挙して一つの Sort Key
に押し込めた状態から考えます。
そして、前巻であった、この中で更新頻度やタイミングが明らかに異なる状態系のデータなどを分離するとした場合にはこのようになります(実際に全部でこれしか属性が無かったら別に分けるほどでもないんですけどねw)
次に、この中で name
で検索をしたい要求があったとしましょう。素直に name
をキーとする GSI
とすることもできますが、そのように次々と要求が増える度に GSI
を定義していけばあっという間に増えて5つという制限を使い切ります。そこで、以下のようにデータの種別を表すプライマリの Sort Key
に name
という値を持つアイテムを追加します。そして、プライマリの Sort Key
である key
を Partition Key
とし、その検索対象となる値を入れるための value
を Sort Key
とする GSI
を作成します。
key = "name" AND value = "Terui"
のように検索できる GSI
になりました。そして、ついでに status
でも検索できるようになっています。つまり、同様に email
でも検索する要求もあるのであれば、同じようにアイテムを作ることで対応可能となるのです。
Sort Key
とすることで、検索だけではなく「登録日時」のように最新(降順ソートの先頭)や範囲検索した一覧を取りたいような場面でも使えます。
GSIのシャーディング
一つのテーブルに対して汎用的に検索できる GSI
が出来ましたが、この GSI
には弱点があります。それは、検索項目毎にPartitionが偏ってしまっていることです。この GSI
に対する検索トラフィックが1Partitionの上限を超える可能性がある場合、 Sharding
を検討する必要があります。
ちなみに1Partitionの物理的な上限は 3000RCU
, 1000WCU
, Index Size 10GB
です。これを超えるユニットやサイズを確保した場合はPartitionが分割されます。その場合は分割されたPartitionに対して確保したユニットが均等に割り振られることに注意が必要です。特定のPartitionにアクセスが集中してスロットリングが発生した場合に、物理的な上限を超えない範囲で他のPartitionに割り振られた余剰分を回す機能(Adaptive Capacity)も持っていますが、スロットリングが実際に発生しないと発動しないので、基本的に安全を見積もっておく必要があります。現在のPartition数を計算する計算式は下記資料のp21, 22に書いています。
www.slideshare.net
この Sharding
をどうやるかというと、計算可能なSuffixを付与する方法があります。例えば、「Unicodeのコードポイント化して積算した数値を200で割った余りに1を足す」などです。
Pythonで雑に書くとこんな感じの計算
import functools functools.reduce(lambda x,y:x*y, [ord(x) for x in 'Terui']) % 200 + 1 # => 161
あえて剰余を取っているのは Partition Key
の数を固定するためです。 Partition Key
の数が不定になる計算だと分散度合いは高くなりますが、範囲検索や集計を取りたいような場合にテーブル全体をフルスキャンする必要が出てしまうからです。数が分かっていればその数だけのインデックスをスキャンするだけに収めることができるので、それだけでもコストは大幅に違います。逆に言うと範囲検索や集計を取ったりインデックス全体に対するオペレーションが必要が全く無ければ不定でも良いです。
それでも現実の名前の分布が均一ではない*1以上ある程度の偏りは想定されますが、一般的なシステムではその程度分散してくれれば十分です。むしろ、その程度の計算で済まないレベルで分散が必要なら、範囲検索や集計を捨てられるなら値そのもの(より大きなサイズを取り得る属性ならハッシュ値)を Partition Key
に含めた別の GSI
を用意するか、別のテーブルに DynamoDB Streams
から連携するべきでしょう。
本筋とはズレますが公式の日本語翻訳の "注文 ID の文字の UTF-8 コードポイント値を乗算して、それを 200 で割って + 1 するなど" は間違ってますね。剰余を取らないと固定数Shardingできない・・・(英語はちゃんと"modulo 200"って書いてるので合ってます。Feedback送っておきましたw)あと、いくらユニークな別の属性と併せて頻繁にクエリすることがあると言っても別の属性を使って計算するのは他の場面で全く使えないので制約強すぎて厳しいと思うんですよね・・・たしかにその方が偏らないけど・・・
name
や email
のように、一つ実体につき一つである属性を意図的に分散させるケースとは違い、そもそも複数の要素を持ち得る属性であるため分散させざるをえない場合もあります。例えば「好きな食べ物 (favorite_foods)」のような任意に複数個設定できるものです。これを検索可能とする場合は少々工夫が必要で、計算可能な固定範囲のSuffixをプライマリの Sort Key
および GSI
の Partition Key
としてしまうと、プライマリの Sort Key
が衝突する可能性があります。そのため、範囲検索や集計を捨てられるなら、値そのものや大きなサイズを取り得る属性ならハッシュ値なSuffixを付けるのが無難です。もし、範囲検索や集計が必要なら別途取り得る値の範囲が固定で計算可能なSuffixを持つ属性を別途追加してそれを Partition Key
とした GSI
を持たせます。
次巻に向けて
今回は例を出しながら実際にテーブル設計を行う際の基本的なアプローチやポイントに触れてきました。書いていると「あ、あのパターンも必要かな」とかけっこうあったけど汎用性の低いものは今回は触れないようにしたので、次巻はもう少しピンポイントな問題を解決するアドバンスドなパターンの中から、わりとよく使うものを紹介する感じになるかもしれません。その前にもうちょっと触れておいた方が良いこともあるような気もするので、そのあたり思いついたらそっちになるかも。
*1:日本は佐藤さん、鈴木さんが多い的な