misc.tech.notes

主に技術的な雑記的な

DynamoDBデータモデリング虎の巻:第参巻 〜実践編〜

前巻のおさらい

前巻はDynamoDBのデータモデリングをするにあたって必要な考え方について、RDBとの対比を交えながら触れてきました。

marcy.hatenablog.com

今回は

この巻では、実際にどのようにデータモデリングしていけば良いのかといった実践的な内容を、いくつかの汎用的と思われるパターンを例にしつつ書き記していきたいと思います。

たぶん、今までの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の情報と紐付けられるのみならず、ユーザが自分の対象アイテムだけにアクセスできるようアクセス制御するのも簡単に実装できます。

docs.aws.amazon.com

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 : 全ての属性が含まれる

docs.aws.amazon.com

このうち、どれを選択すべきかは、どのようなデータアクセスが多いかによって決まります。まず、GSI に含める属性が増えれば増えるほどインデックスの容量と更新コストが増大します。 GSI の更新は実テーブルの更新に対して自動的に非同期で適用されますが、その際に使用されるキャパシティユニットが増大するということです。もし含めなかったとしても、最低限含まれるプライマリキーによって実テーブルからデータが都度フェッチされるため見かけ上のオペレーションとしての差異はありません

では、どのような場合にインデックスに含める属性を増やすかというと、そのインデックスに対してクエリを行う際に頻繁に取得する属性がある場合です。インデックスに含まれている属性は実テーブルからデータをフェッチする必要がなくなり、特定のPartition内だけで読み込みが完了するため読み込み効率が上がります。いわゆる、MySQLで言う所の Covering IndexPostgreSQLで言う所の Index Only Scan です。そのようなクエリが効果的かつ頻繁に想定される場合、更新コストや容量の効率および課金と天秤にかけて KEYS_ONLY 以外を選択します。

ちなみにここから先は実際のデータを模した二次元表が出てきますが、 GSI には暗黙的にプライマリキーが含まれていると思って見てください。

Sort Keyの決め方と汎用的なGSI

前巻で書いた「できる限りテーブルを少なくする」というものは、同じように GSI にも適用されます。 前巻でも述べたように、 GSI は元となるテーブルに対する射影として実体を持つ別のテーブルと言えるものであり、独立したキャパシティの管理が必要となります。つまり、多ければ多いほど管理コストが増します。そして何より、 GSI1テーブルあたり5つまでという制限を超えないよう気を使わなくてはならず、これは「できる限りテーブルを少なくする」という方針と対立します。だから、 GSI の使い方はとても重要なのです。

Partition Keyある実体に対する一意な識別子と比較的シンプルに決めることができますが、 Sort Key についてはその実体に対するアクセスパターンを十分に踏まえる必要があります。とはいえ、ある程度順序立てて決めていくことが可能です。

まず、前巻で述べたようにある実体に対して必要なデータを列挙して一つの Sort Key に押し込めた状態から考えます。

f:id:FumblePerson:20180804020600p:plain

そして、前巻であった、この中で更新頻度やタイミングが明らかに異なる状態系のデータなどを分離するとした場合にはこのようになります(実際に全部でこれしか属性が無かったら別に分けるほどでもないんですけどねw)

f:id:FumblePerson:20180804020730p:plain

次に、この中で name で検索をしたい要求があったとしましょう。素直に name をキーとする GSI とすることもできますが、そのように次々と要求が増える度に GSI を定義していけばあっという間に増えて5つという制限を使い切ります。そこで、以下のようにデータの種別を表すプライマリの Sort Keyname という値を持つアイテムを追加します。そして、プライマリの Sort Key である keyPartition Key とし、その検索対象となる値を入れるための valueSort Key とする GSI を作成します。

f:id:FumblePerson:20180804230628p:plain

key = "name" AND value = "Terui" のように検索できる GSI になりました。そして、ついでに status でも検索できるようになっています。つまり、同様に email でも検索する要求もあるのであれば、同じようにアイテムを作ることで対応可能となるのです。

f:id:FumblePerson:20180804230731p:plain

Sort Key とすることで、検索だけではなく「登録日時」のように最新(降順ソートの先頭)や範囲検索した一覧を取りたいような場面でも使えます。

f:id:FumblePerson:20180804230827p:plain

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 から連携するべきでしょう。

f:id:FumblePerson:20180804230945p:plain

docs.aws.amazon.com

本筋とはズレますが公式の日本語翻訳の "注文 ID の文字の UTF-8 コードポイント値を乗算して、それを 200 で割って + 1 するなど" は間違ってますね。剰余を取らないと固定数Shardingできない・・・(英語はちゃんと"modulo 200"って書いてるので合ってます。Feedback送っておきましたw)あと、いくらユニークな別の属性と併せて頻繁にクエリすることがあると言っても別の属性を使って計算するのは他の場面で全く使えないので制約強すぎて厳しいと思うんですよね・・・たしかにその方が偏らないけど・・・

nameemail のように、一つ実体につき一つである属性を意図的に分散させるケースとは違い、そもそも複数の要素を持ち得る属性であるため分散させざるをえない場合もあります。例えば「好きな食べ物 (favorite_foods)」のような任意に複数個設定できるものです。これを検索可能とする場合は少々工夫が必要で、計算可能な固定範囲のSuffixをプライマリの Sort Key および GSIPartition Key としてしまうと、プライマリの Sort Key衝突する可能性があります。そのため、範囲検索や集計を捨てられるなら、値そのものや大きなサイズを取り得る属性ならハッシュ値なSuffixを付けるのが無難です。もし、範囲検索や集計が必要なら別途取り得る値の範囲が固定で計算可能なSuffixを持つ属性を別途追加してそれを Partition Key とした GSI を持たせます。

f:id:FumblePerson:20180804234442p:plain

次巻に向けて

今回は例を出しながら実際にテーブル設計を行う際の基本的なアプローチやポイントに触れてきました。書いていると「あ、あのパターンも必要かな」とかけっこうあったけど汎用性の低いものは今回は触れないようにしたので、次巻はもう少しピンポイントな問題を解決するアドバンスドなパターンの中から、わりとよく使うものを紹介する感じになるかもしれません。その前にもうちょっと触れておいた方が良いこともあるような気もするので、そのあたり思いついたらそっちになるかも。

*1:日本は佐藤さん、鈴木さんが多い的な