misc.tech.notes

主に技術的な雑記的な

DynamoDBデータモデリング虎の巻:第弐巻 〜考え方編〜

前巻のおさらい

前巻はDynamoDBのデータモデリングをする前に知っておいた方が良いDynamoDB自体の仕組みやデータ構造のお話でした。

marcy.hatenablog.com

今回は

今回はデータモデリングを行う際に必要なマインドセット、つまり「考え方」について書き記したいと思います。非常によく聞かれる「RDBとの考え方の違い」といった切り口で進めていきたいと思います。

RDBとはアプローチが真逆

RDBのデータモデリングをする場合、まず正規化されたデータのスキーマを決めることから始めると思います。慣れてくると律儀に第一正規化から始めずにいきなり第三正規形あたりから設計しだすことも多いと思います(私もそうです)

そして、データのスキーマが決まってからそれに対してどのようにアクセスするか(=SQL)をアプリケーションを設計する際に考えていくというのがRDBでの一般的なアプローチではないかと思います。

これ、DynamoDBでは全く逆になります

DynamoDBのデータモデリングでは、まず先にデータにどのようにアクセスするかを考えます。そして、それに合わせたデータモデルを作るのです。つまり、データモデルを設計してからアプリケーションの設計を行うことはDynamoDBではしません。アプリケーションとデータモデルは同時に設計するのです。

これは、 スキーマレス なDynamoDBだからこそできることです。アプリケーションの仕様が変わってもスキーマを変更するコストは極小で済みます。というか、スキーマが無いんだから変更コストも何も無いと言えばそうなんですがw

かといって、既存のデータを変換する必要があるような変更をしてしまうとそれは大きなコストがかかります。それを避けるために大枠として抑えておくべきポイントもあるので、そのあたりにも触れていきたいと思います。

テーブルの数を少なくする

RDBでは正しく正規化していくとテーブルの数は基本的に増えていきます。同じ実体に対して論理的に分かれたテーブル間は リレーションシップ という概念とそれに付随した 外部キー制約 という整合性を守る仕組みによって整合性が取られます。 外部キー制約 使ってますか?使わないとRDBでも整合性は守られないですからね??(煽り芸)

しかし、DynamoDBには リレーションシップ という概念はありません。だから、極論テーブルを分けても別に良いことなんて無いのです。

テーブルが少なくなると何が良いかというと、キャパシティの最適化ができます。アクセス頻度が少ないテーブルと多いテーブルに分かれているとそれぞれのキャパシティの管理が難しくなります。特にAutoScaleはキャパシティが少ないと効果的に動作しません。*1テーブルを少なく保つことでキャパシティを合計で考えることができ、AutoScaleも効果的に使えるようになります。

テーブルが少ないと負荷の集中が気になりますか?前巻の内容をよく思い出してみてください。Partitionが分散さえすればテーブルを分けなくても負荷は分散するのです。

そして、DynamoDBは スキーマレス です。 スキーマレス であるということは、データにListやMapといったデータ構造を持たせられることでも、後から属性を自由に追加・変更・削除できるということでもありません。プライマリキーの型さえ合っていれば同じテーブルになんでも突っ込めるのです。

DynamoDBの公式ドキュメントにあるベストプラクティス集にはこう書いてあります。

DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。

docs.aws.amazon.com

さすがにアプリケーションにつき1つはプライマリキーの型を合わせないといけないことも考えるとやりすぎ感もあるし(全部文字列にしちゃえばできないことはないんですが)、Serverlessである程度の規模以上のシステムを設計していくとMicroservices的なサービス分割は切っても切れない関係になるので、サービス間で同じテーブルを共有するのはなんとも気持ちが悪い部分があります。

そこで、私は少なくとも1つの実体について1つのテーブルに収めるようにしています。この「1つの実体」というのはECサイトなら「ユーザ」「商品」「注文」といった特定の意味のある存在を表す実体です。これはMicroservices的な各サービスが担うべき特定の業務ドメイン領域にも多くの場合に合致します。そして、これら1つの実体は基本的にユーザIDや注文番号、SKUといった識別子を持っているものです。これをPartition Key とするのです。

トランザクションが無くても1アイテムの更新はアトミック

トランザクションとは何のためにあるのでしょうか?RDBトランザクションが守るのは主にデータの 原子性(Atomicity)独立性(Isolation) です。*2それが問題になるのは複数のレコードを更新する一連の処理における話です。単一のレコード(DynamoDB的にはアイテム)を更新する処理についてはDynamoDBでも 原子性 が保証されています。また、1アイテムしか更新しない処理は当然独立しています。

つまり、ListもMapも扱えるDynamoDBではある実体に関連するデータを全て一つのアイテムに押し込めることができ、それを更新している限りは原子性と独立性が崩れることはないということです。さらに言えば、一貫性(Consistency) もデータ間の関連性がそのアイテムに適切に詰め込まれていれば 条件付き書き込み によって担保することができます。400KBという1アイテムのサイズ制限に気を使う必要はありますが、そうそう超えるものじゃありません。

docs.aws.amazon.com

docs.aws.amazon.com

とはいえ完全に一つに突っ込んでしまうとキャパシティの無駄遣いや性能面での懸念もあります。それをどう分けるかという面でもまたデータアクセスが重要となってくるのです。例えば、あるユーザの基本的な「名前」や「住所」といった「情報」と、「ログイン中」などといった「状態」は明らかに更新タイミングが違うはずです。こういったデータは分割することでキャパシティ効率がよくなります。更新タイミングが違うのであれば1回の操作につき1つの更新になるため以下同文です。

そして、同じ実体に対する Partition Key のデータを分割するならどうするか?そこで Sort Key の出番というわけです。

user_id (PK) data_key (SK) attrs
aaa info 〜〜〜
aaa status 〜〜〜
bbb info 〜〜〜
bbb status 〜〜〜

それでも整合性の担保ができないものもある

それは、トランザクションの代表例とも言える口座間取引のような実体として複数に跨って整合性を取る必要のある処理です。全く別の実体である複数の口座を一つのアイテムにすることは現実的ではありません。この場合は、諦めてRDBを使う、まず必ず担保すべき「移動元の口座の残高が0を下回らないこと」だけを条件付き書き込みで担保してそれ以外は結果整合性を受け入れる、頑張ってトランザクションを再実装する、といったちょっとつらめの対策になります。

f:id:FumblePerson:20180802001414p:plain

www.slideshare.net

ただし、私はServerlessでもRDBを使うことは全く問題ないと考えています。Serverless(Lambda)とRDBの相性が悪いのはコネクションモデルと現状避けられないVPC内で起動するLambda FunctionのENI生成オーバヘッドによるものです。つまり、オンラインで使おうとするから悪いのです。これらは、トランザクションが必要な処理をKinesisを挟んだバックグラウンド処理に置き換えることで、コネクション数をコントロールすると共に一定数が常時起動となることによってオーバヘッドも避けることが可能です。そして、RDBによるトランザクションの成功を以てDynamoDBのデータも更新して、オンラインの参照はそちらを見るようにすれば良いのです。結果整合さえ受け入れられればやり方はいくらでもあります。

このように、DynamoDBでも限定的ではありますがACIDを守る方法はあります。「DynamoDBはBASEで結果整合性だからACIDは捨てなきゃいけない」とか言っちゃうなんちゃってアーキテクトが如何に厚顔無恥か分かりますね!(煽り芸)

書き込むデータと読み込むデータは別で良い

RDBは正規化することで特定の事実を示すレコードを一箇所とし、データの整合性を守ります。これは、 リレーションシップ外部キー制約 という整合性を守る仕組みと、必要なら JOIN をすることでその一箇所にしかない事実を的確に取得できる仕組みに裏打ちされたものです。そして、DynamoDBは JOIN ができません。また、前述したようにテーブルは極力少なくしなくてはなりません。

そんな リレーションシップJOIN も無いDynamoDBで、どのように整合性を守りつつ読み込みの効率化をすれば良いのでしょうか?その答えが、読み込みと書き込みの分離です。

CQRS

CQRS (Command Query Responsibility Segregation) という考え方があります。Command(更新操作)とQuery(データ読み込み)を責任分界を行い、データそのものも分けてしまおうという考え方です。 書き込みに都合が良いデータ読み込みに都合が良いデータ は異なります。両者を分離し、前者を更新するイベントをイベントソースに積み上げて、それを以て後者を更新するのが CQRS の一つの形です。

martinfowler.com

postd.cc

書き込みに都合が良いデータ というのは前述したように1度のオペレーションで更新されるべきデータが一つのアイテムに詰まったものです。対して、 読み込みに都合が良いデータ はそうではありません。様々な属性が詰まったアイテムに対して検索に使用する全ての属性に GSI を張っていてはあっという間に GSI の5つという制限を使い切ってしまいます。そこで、 書き込みに都合が良いデータ が更新されたことをトリガーとして、 読み込みに都合が良いデータ を生成するというわけです。

非常に都合の良いことに、DynamoDBにはそれを実現するためにとてもマッチする DynamoDB Streams という仕組みを持っています。DynamoDBのあるテーブルに対する更新操作が流れてくるStreamです。ここに流れてきた更新情報を元に、 読み取りに都合が良いデータ を生成するコマンドを発行していくというわけです。

f:id:FumblePerson:20180801234414p:plain

データに対する整合性は属性の詰まったアイテムを更新することで書き込み時に担保されているので、それを元に更新している限りは整合性が崩れることはありません。もちろん、非同期更新となるので結果整合にはなりますが。

また、オンラインの書き込みのオペレーションとしては一度で済むためとても処理がシンプルになります。読み込み用のデータを生成する処理は一つ一つが独立したバックグラウンドで行われる処理となるため、仮に失敗してもリトライすれば良い話です。もしこれがオンラインで全て同期でやるとしたらハンドリングしたくないですが、適切に分解することで一つ一つの処理はシンプルに保つことができます。

そして、読み込みのためのデータを生成する処理が分かれていることにより、要求の変化によってデータ構造を変えないといけないといったことを避けることができます。今あるデータや処理を変えずに新しい要求に合わせたデータを生成する処理を追加するだけで良いのです。

次巻に向けて

次巻は「じゃあ、 読み込みに都合の良いデータ ってなんだ」ということで、次こそ具体的なデータ構造とインデックスの使い方に入っていきたいと思います。

次巻

書きました

marcy.hatenablog.com

*1:極端な例を出すと1000RCUの90%閾値でAutoScaleにすると900でスケールしてくれますが、10だと9になるまでスケールしません。残り1しかない。。。

*2:独立性についてはRDBもPhantom Readを妥協する等していますが