DynamoDBの設計を始めてからたいていの人が引っかかるであろう、DynamoDBにおける多対多(many-to-many relationship)の対応について、つらつらとメモする。
AWSのドキュメントにベストプラクティスがあるので、それについて書き連ねるだけではある。
DynamoDBの2番目くらいの壁
ウキウキしながらDynamoDBを始めた後、だんだん「あれ?これクエリめっちゃ厳しくない?」ということに気づき、その制約に愕然とするパターンの一つが、いわゆる多対多の実装だと思う。RDBなら中間テーブル作ってリレーション貼って正規化すればよし、だけれど、同じことをDynamoDBですると、めちゃくちゃクエリ投げないといけないことにハタと気づく。すごく思う。joinしたい。
もちろんDynamoDBにjoinはない。ああなるほど、KVS、Key Value Storeってそういう……いや知っていたんだけれど、ああ、そうか……などと思う。
どうすりゃいいのか。大丈夫、多対多の解決なんてメジャーなトピック、当然考えら得られている。というか、AWSの公式ドキュメントに目的ズバリの項目がある。
多対多の関係を管理するためのベストプラクティス - Amazon DynamoDB
あるにはあるが、その書かれ方は例によって例のAWS公式ドキュメントな感じなので、初心者がこれ読んですぐに理解できるかはまた別の話ではある。なので、この記事がある。
ユーザーとグループの関係で考えてみる
ここで取り上げるのは、最初のAdjancy List Design Patter(隣接関係のリスト設計パターン)だ。これは実際やってみると、RDBで中間テーブルを置く結果とほぼ重なるのだが(僕は不勉強で隣接リストをきちんと理解できていないが、多分本質的に同じ考え方なんだろうと思う)、その見え方は随分と異なる。
具体的な手法の話は具体的な例示を元に考えるに限る。実際、AWSのドキュメントも具体例を提示している。なので、僕もそうして考える。
典型的な多対多
今回は、ユーザーとグループをDynamoDBで実現することを考えてみる。グループにはもちろん複数のユーザーが所属する。一方、ユーザーもまた複数のグループに所属することができる。理論上、ユーザーのいないグループやグループに所属しないユーザーも存在する。
つまり、ユーザーとグループは典型的な多対多の関係である。
中間テーブル作戦
これをRDBで考えるならば、中間テーブルを用意して1対多の関係に持ち込むだろう。つまり、ユーザーテーブルとグループテーブルに加えて、ユーザーとグループのリンクを示した中間テーブルを作るのである。で、中間テーブルはユーザーテーブルとグループテーブルのそれぞれと多対1の関係を持つ。つまりは以下のようになる。

よくあるパターンだ。
この手法はDynamoDBでも機能する。中間テーブルではuser_idをPK、group_idをSKとし、一方またgroup_idをGSI-PKとするのである。

そうすることで、user_idが所属するgroup_idも、またgroup_idに所属するuser_idもクエリで引っ張ってこれる、というわけだ。
確かに機能するが、あまりDynamoDBらしくはない。というか思いっきりRDB的に思える。DynamoDBのベストプラクティスには、「DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。」とある。なのに、いきなりテーブル3つ作ってる。うーん。
これでいいのだろうか、などと思いながら、実際にアプリケーションを開発し始めると、問題が起きる。
たとえば、ユーザーが所属するグループの一覧を表示したい時を考えてみよう。この場合、中間テーブルでuser_idをPKにして、queryすればいい。そうすると、group_idの一覧が取得できる。そうしたら、group_idでgroupsテーブルにget_itemなクエリを投げるのだ。
n + 1問題
この方法は機能する。機能するが、すぐに問題だとわかる。ユーザーの所属するグループが5つくらいならば、5回クエリを投げればよいが、もし100のグループに所属していたら、100回クエリを投げる必要がある。おおぉ。
この問題は俗にn+1問題と言われる。やってみればわかるが、明らかにパフォーマンスが悪い。当たり前だ、余計な通信コストが大きすぎる。負荷も大きい。
まぁ、実際のところけっこうこれでもなんとかなる。が、どう考えてももっといい方法がある気がするわけだ。で、実際ある。先の公式ドキュメントのリンクがその内容で、ここからはそれについて書いていく。
Adjancy List Design Pattern(隣接リストパターン)
公式ドキュメントでは多対多の解決の一例としてAdjancy List Design Pattern(隣接リスト)を提案している。これは何か、多分グダグダいうより見たほうが早い。
user_id (PK) | group_id (SK, GSI-PK) | attributes... |
userA | userA | userAの詳細情報 |
userA | groupA | groupAの情報 |
userA | groupB | groupBの情報 |
userB | userB | userBの情報 |
userB | groupA | groupAの情報 |
groupA | groupA | groupAの詳細情報 |
groupB | groupB | groupBの詳細情報 |
groupC | groupC | groupCの詳細情報 |
たった1つのテーブルで構成している。特徴的なのは、user_id = group_id になっているアイテムだ。初見ではギョッとするかもしれない。このテーブルは、以下のようになっている。
- PK=SK(GSI-PK)=user_idのとき、ユーザーを示す。
- PK=SK(GSI-PK)=group_idのとき、グループを示す。
- PK=user_id, SK(GSI-PK)=group_id のとき、user_idの所属するグループを示す
- つまり、group_idに所属するuser_idの数だけ冗長である
したがって、この表は以下のように読み解ける。
- userAはgroupA, Bに所属している
- userBはgroupAに所属している
- groupCは誰も所属していない
最初は違和感を感じるが、よく見るとこれは中間テーブルにそのまま属性をもたせたようなものである。やってみるとこれはとてもよく考えられた設計であることがわかる。
たとえば任意のユーザーについて詳細情報を知りたい時は、PK=SK=user_id としてget_itemしたら良い。任意のグループの詳細情報であれば、PK=SK=group_id だ。
あるユーザーの所属するグループの情報ならば、PK=user_id でクエリをかける。逆に、あるグループに所属するユーザーの情報ならば、GSI-PK(SK)=group_id でクエリをかける。
ほしい情報をだいたいクエリ一発で取れるようになっているのだ。なるほど、よくできている。が、いくつかの問題もある。
冗長性の問題
それぞれのアイテムには、詳細ではないが一覧表示程度には十分な情報が含まれている。これらの情報は冗長なので、データベースとしてはその分余分に容量を食うことになる。クエリの通信コストにもかかるし、どれくらい冗長させるかは考えどころだ。
また、整合性の問題もある(というかこちらが大きいだろう)。整合性が絶対に必要ならば裏で非同期処理をかましたり、バッチ処理したり、リアルタイム性もいるならTransaction処理が必要かもしれない(DynamoDBも10アイテムならいける)。ちょっとくらい間違えていても問題なくて、かつ頻繁に更新がかかるなら、時々整合性が取れない状態になることを覚悟で特に処理しない、という方法も考えられる。
一覧の問題
また、このやり方だとユーザー一覧とかグループ一覧が厳しい。PK=SKとせず、たとえばユーザー詳細のアイテムはPK=user_idでSK(GSI-PK)="user"で固定という風にすると、GSI-PK(SK)="user"とした時にユーザー一覧を引っ掛けることができる。ただし、この場合一つのパーティション(GSI-PK=user)に情報が集中することにもなる。だが条件によっては一考の余地がある。強引だが、専用のテーブルを冗長覚悟で作ってしまうのも方法の一つだろう。
正解はない
DynamoDBの設計に絶対の正解はなく、サービスの要件によるとしか言えない。ここで思い出すのは「答えが必要な質問が分かるまで、スキーマの設計を開始しないでください。」という公式ドキュメントの無下な一文である。まぁそれはこういうことなのだろう。逆に言うと、要件がわかっていれば、うまい設計が考えられるはずなのだ。
目指すは美しさより力強さ
ということで、このパターンは多対多の解決として素晴らしく機能することがわかるのだが、わかったうえで、なんかこう、強引なインテリって感じ。確かにこれでできるけどマジか、みたいな。
結果をよくよく見ていると、中間テーブルに重複データ覚悟で属性をもたせている、ように見える。というか、多分本質的にはそういうことなんじゃなかろうか。僕は不勉強で隣接リストについてきちんと理解できていないのだが、中間テーブルとこの隣接リストは概念的に同じようなことをしようとしているような。隣接って、こういうことかな。

矢印一つで示せる関係。なんか曖昧で申し訳ないんだが、多分根底の理屈は一緒なんだと思う。
しかし、その見え方は大きく異なる。中間テーブルはその名のとおり中間であって、主眼に置かれることはあまりない。一方で、DynamoDBにおいてはこれこそが本体、主役である。
結果的に同じようなものであったとしても、根本的な思想が違うということだろう。よくできたRDBの設計は美しい。一方、DynamoDBを始めNoSQL系DBのよくできた設計は、美しいというより、力強い、という表現が個人的にはしっくりとくる。そんなことまで考えていたのか!なんて頼もしいんだ!みたいな。力強い設計を目指したい。
コメント