Closed8

チャットアプリの実例でDynamoDBのモデリングを考えてみる

DynamoDB むずい。具体的には

  • テーブル設計がむずい
  • カラムのrenameやテーブル名のrenameがむずい
  • テーブル設計にも関わるが、検索がパーティションキーとソートキーを通してしかできない。もしくは、別にindexテーブルをつくるGSIやLSI機能を利用する。

RDBのように正規化して保存するのではなく、欲しいデータをほしい形で保存するみたいに言われるけど、結局データの持ち方の理想形がわからない。

また、既存のカラムのrenameができなかったり、テーブル名のrenameができなかったりする。
これらのrenameがしたい場合は、dynamodbのstreamという機能を使ってデータを新しいカラム(もしくはテーブル)に流しつつ、既存のデータをコピーしないと行けない。しんどい。

既存のテーブルの形の修正がしにくいから、結局最初に設計した未熟なテーブルの良くない部分をアプリケーションによって吸収しないといけない。

チャットアプリケーションのメッセージ取得がioが激しいので、dynamodbを選択したが、設計能力が未熟であれば修正がしやすいRDBを選択すればよかったかも。もしくは、検索まわりがやさしいmongodbとか(AWSはmongodb互換のdocumentDBを提供しています。結構高い)

以下は自分の設計スキル不足をさらすようで嫌ですが、とりあえず流れを全部見せようと思います。

最初の設計

とりあえずdynamoDBの設計を見直そうと思うので、いい機会なのでこのスクラップで流れを共有していく。

設計しているのはチャットアプリ。既存のテーブルはつぎのようになっている。
DynamoDBはDate型をサポートしていないので、時間はtimestampで保存する。
また、roomやuserテーブルはRDBで管理している。
roomIdがハッシュキー。createdAtをソートキーにしている。

roomId createdAt content updatedAt postUserId
1 1606645200456 Hello from Zenn! 1606645200456 1
1 1606645201000 Good evening from Zenn! 1606645201000 2
2 1606645200456 こんちには! 1606645200456 1
2 1606645201000 こんばんは! 1606645201000 2

このように設計することで、ソートキーは自動的にソートしてテーブルに保存してくれるので、ハッシュキーを指定すれば、該当の部屋のメッセージがすべて取得できる。

チャットアプリの種類

LINE型のチャットアプリ

上記のテーブルはLINE型のチャットアプリしか実現できない。どういうことかというと

roomId createdAt content updatedAt postUserId
2 1606645200456 こんちには! 1606645200456 1
2 1606645201000 こんばんは! 1606645201000 2
2 1606645202000 まだ昼だよ! 1606645202000 1
2 1606645203000 こっちは夕方だよ! 1606645203000 2
2 1606645204000 おじゃまします! 1606645204000 1
2 1606645205000 あっ、時差があるのね。 1606645205000 2
user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
user2: こっちは夕方だよ!
user3: おじゃまします!
user1: あっ、時差があるのね。

のように、メッセージの返信が一番下に来る。

Slack型のチャットアプリ

ちなみに、LINE型に対して、こちらの返信があるメッセージにひもづくのをSlack型と呼ぼうと思います。

user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
           user2: こっちは夕方だよ!
           user1: あっ、時差があるのね。
user3: おじゃまします!

Reddit型

無限にネストするのをとりあえずReddit型と名前付けします。

user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
           user2: こっちは夕方だよ!
                      user1: あっ、時差があるのね。
user3: おじゃまします!

Slack型を実現したい

ビジネスサイドと話す中で、LINE型よりもSlack型のほうが会話しやすいという事になりました。そこでSlack型を実現しなければなりません。

replyId保持方式

Slack型を最初のテーブルに2つのカラムを追加して実現しました。

roomId createdAt content updatedAt postUserId id replyToId
2 1606645200456 こんちには! 1606645200456 1 random1
2 1606645201000 こんばんは! 1606645201000 2 random2
2 1606645202000 まだ昼だよ! 1606645202000 1 random3
2 1606645203000 こっちは夕方だよ! 1606645203000 2 random4 random3
2 1606645204000 おじゃまします! 1606645204000 1 random5
2 1606645205000 あっ、時差があるのね。 1606645205000 2 random6 random3

idという複合キーとは別にシステムで勝手につけたユニークキーをつけます。
idはランダム文字列。replyToIdに返信先のメッセージのidを格納することで、アプリケーション側で対応しました。

const messages = Message.findMessagesByRoomId(2) // 裏側にdynamodbのdocClientがある。
for(let i =0; i < messages.lenght; i ++){
  // もしreplyToIdにidがあればはけておき、あとから該当のidをもつメッセージを取得して、subMessagesとして持たせておく。
}

みたいな感じです。

あと、idという複合キーとは別に単一のプライマリーキーをもたせることで、削除や編集apiがスッキリしました。このidにはindexを貼ってます(GSI)

Thread方式

1つ前では replyToIdというカラムを使って、Slack型を実現しました。ただ、これは予想なのですが、slackのDBはそうはなっていないと思います。

Threadという考えを取り入れているからです。

便宜的にSlack型でインデントしていないメッセージを「メインツリー」。インデントしてある返信メッセージを「サブツリー」と呼ぶことにします。ここでメインツリーのメッセージはひとつひとつがThreadになります。そして返信が始まると、そのThreadに返信メッセージが格納されます。

Threadのjsonのイメージはこんな感じです。

[
{
  threadId: rundom1,
  main: {
    content: 'こんにちは!',
    postUesrId: 1,
    createdAt: 1606645200456
  }
  sub: []
},
{
  threadId: rundom2,
  main: {
    content: 'こんばんは!',
    postUesrId: 2,
    createdAt: 1606645201000
  }
  sub: []
},
{
  threadId: rundom3,
  main: {
    content: 'まだ昼だよ!',
    postUesrId: 1,
    createdAt: 1606645202000
  }
  sub: [
     {
        content: 'こっちは夕方だよ!',
        postUserId: 2,
        createdAt:  1606645203000
      },
     {
        content: 'あっ、時差があるのね。,
        postUserId: 1,
        createdAt:  1606645205000
      }
  ]
}
]

これをDBに再現するとしたら次のようでしょうか。

roomId threadCreatedAt_createdAt content updatedAt postToUserId
2 1606645200456_1606645200456 こんにちは! 1606645200456 1
2 1606645201000_1606645201000 こんばんは! 1606645200456 2
2 1606645202000_1606645202000 まだ昼だよ! 1606645202000 1
2 1606645202000_1606645203000 こっちは夕方だよ!' 1606645204000 2
2 1606645202000_1606645205000 あっ、時差があるのね。 1606645205000 1
2 1606645204000_1606645204000 おじゃまします! 1606645204000 3

ソートキーが変わりました。threadCreatedAt_createdAt になっています。これはthreadの作成時間とメッセージの作成時間を組み合わせることで、

  • スレッドは作成順に並ぶ
  • 返信メッセージはスレッドに紐付いて作成順に並ぶ
  • 部屋単位でメッセージを取得したい

といった要求をソートキーの仕組みを使って満たしています。

特定のthreadだけを取得したい場合は、ソートキーの検索の際にbegins_withという機能を使えば、1606645202000を指定して、1606645202000に紐づくthreadとメッセージだけ取得することもできます。

ただ、この当時はThreadという概念を知らなかったのと、dynamodbの扱いになれていないのもあって、単純にreplyToIdで実現できるやり方を採用しました。

ソートキーのベストプラクティス的にはこれが正解だと思います。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-sort-keys.html

json方式

ハッシュキーとソートキーのみ外だしして、あとはjsonに入れるパターンです。jsonないにはいっているメッセージひとつひとつにはインデックスがはれない(検索できない)ので一旦親経由でsubはまるごと更新する必要があります。

また、dynamodbはソートキー以外では自動的にソートしてくれないので、json以下の配列は自身でソートして入れる必要があります。

main0 = {
    content: 'こんにちは!',
    postUesrId: 1,
    createdAt: 1606645200456
}

main1 = {
    content: 'こんばんは!',
    postUesrId: 2,
    createdAt: 1606645201000
}

main2 = {
    content: 'まだ昼だよ!',
    postUesrId: 1,
    createdAt: 1606645202000
  }

main3 = {
    content: 'おじゃまします!',
    postUesrId: 3,
    createdAt: 1606645204000
  }

sub2 = [
       {
        content: 'こっちは夕方だよ!',
        postUserId: 2,
        createdAt:  1606645203000
      },
     {
        content: 'あっ、時差があるのね。',
        postUserId: 1,
        createdAt:  1606645205000
      }
]
roomId createdAt main sub
2 1606645200456 main0 []
2 1606645201000 main1 []
2 1606645202000 main2 sub2
2 1606645204000 main3 []

第3の返信ネスト(インデント)をどう実現するか。

ビジネスサイドと会話しているうちに、第3のネストが必要なことがわかってきました。

それはスタンプ機能を実現するためです。スタンプや画像もメッセージの一部として作っていました。

roomId createdAt content type
2 1606645200456 こんにちは! message
2 1606645201000 https://example.com/hogehoge.jpg stamp

みたいな感じです。上のセクションでモデリングした状態だとこuser2の「こっちは夕方だよ」というメッセージに対してこうなっちゃいます。

user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
           user2: こっちは夕方だよ!
           user1: あっ、時差があるのね。
           user3: [スタンプ]
user3: おじゃまします!

user3はuser2に対して返信スタンプを押したはずなのに、user1に押したみたいになってます。スタンプはuser2のコメントの下に来てほしいのです。

user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
           user2: こっちは夕方だよ!
           user3: [スタンプ]
           user1: あっ、時差があるのね。
user3: おじゃまします!

設計から見直すパターン

そもそもスタンプをメッセージとして設計しているのが悪いので、type=stampではなく、他にカラムを作りスタンプの設計をします。

replyToId方式から拡張するパターン

roomId createdAt content updatedAt postUserId id replyToId type
2 1606645200456 こんちには! 1606645200456 1 random1 message
2 1606645201000 こんばんは! 1606645201000 2 random2 message
2 1606645202000 まだ昼だよ! 1606645202000 1 random3 message
2 1606645203000 こっちは夕方だよ! 1606645203000 2 random4 random3 message
2 1606645206000 https://example.com/hogehoge.jpg 1606645206000 3 random7 random4 stamp
2 1606645204000 おじゃまします! 1606645204000 1 random5 message
2 1606645205000 あっ、時差があるのね。 1606645205000 2 random6 random3 message

アプリケーション側の実装を拡張して第3層を実現します。
スタンプのreplyToIdがrandom4になっているのがわかると思います。

raondom3 -> random4 -> random7

のように、メッセージがつながっています。

アプリケーション側のコードは省きます。

しかしこの設計だと、randome -> random4 -> random7 とつなげるのに、基本3重ループ以上が必要になり、アプリケーション側のその部分のコードだけめちゃくちゃ大きく、メンテしづらいものになりました。もっと素直にDBで表現したいところです。

Thread方式から拡張するパターン

単純に考えるとこんなかんじでしょうか。

roomId threadCreatedAt_createdAt content updatedAt postUserId type
2 1606645200456_1606645200456_0000000000000 こんにちは! 1606645200456 1 message
2 1606645201000_1606645201000_0000000000000 こんばんは! 1606645200456 2 message
2 1606645202000_1606645202000_0000000000000 まだ昼だよ! 1606645202000 1 message
2 1606645202000_1606645203000_0000000000000 こっちは夕方だよ!' 1606645204000 2 message
2 1606645204000_1606645204000_1606645205000 https://example.com/hogehoge.jpg 1606645205000 3 stamp
2 1606645202000_1606645205000_0000000000000 あっ、時差があるのね。 1606645205000 1 message
2 1606645204000_1606645204000_0000000000000 おじゃまします! 1606645204000 3 message

ソートキーにbegins_withを使えば、そのスレッドのメッセージ一覧、もしくは第2層の特定のメッセージに紐づく第3層のメッセージ一覧を取得できそうです。

ただ、すでに既存のテーブルが存在する場合、データを上記の様に加工しなおして、入れ直す必要があります。

json方式から拡張するパターン

jsonの値が一つ増える感じです。素直ですね。

main0 = {
    content: 'こんにちは!',
    postUesrId: 1,
    createdAt: 1606645200456
}

main1 = {
    content: 'こんばんは!',
    postUesrId: 2,
    createdAt: 1606645201000
}

main2 = {
    content: 'まだ昼だよ!',
    postUesrId: 1,
    createdAt: 1606645202000
  }

main3 = {
    content: 'おじゃまします!',
    postUesrId: 3,
    createdAt: 1606645204000
  }

sub2 = [
       {
        content: 'こっちは夕方だよ!',
        postUserId: 2,
        createdAt:  1606645203000,
        stamps: [
          {
            url: 'https://example.com/hogehoge.jpg'
          }
        ]
      },
     {
        content: 'あっ、時差があるのね。',
        postUserId: 1,
        createdAt:  1606645205000
      }
]
roomId createdAt main sub
2 1606645200456 main0 []
2 1606645201000 main1 []
2 1606645202000 main2 sub2
2 1606645204000 main3 []

ドキュメントからみる設計ベストプラクティス

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html

長い間このドキュメントの意味がよくわからなかったのですが、ようやく理解できました。

やり方なのですが、まずはRDBにおけるテーブルに位置するやつ(Entity)をエンティティ図ではなくグラフで再現します。

例えばチャットアプリの場合は、

  • ルームにいくつかユーザーが参加している
  • ユーザーはそれぞれメッセージを送ることができる
  • メッセージは返信ができる
  • 返信メッセージに対しても返信ができる

とします。これをグラフで再現するとこんな感じでしょうか?

DynamoDBでは、これを隣接関係リスト設計パターンを使って実装します。やり方はかんたんです。

ハッシュキーとソートキーにそれぞれのノード(丸いやつ)を設定するのです。
どういうことかというと、

room1は user1 と user2 と message1 につながってますよね。dynamodb上ではこう表現します。

hashkey sortkey data
room1 user1 太郎
room1 user2 花子
room1 message1 これはメッセージです

つぎに、message1 をみますと、 user1, reply1, reply2 とつながっています。これを加えます。

hashkey sortkey data
room1 user1 太郎
room1 user2 花子
room1 message1 これはメッセージです
message1 user1 太郎
message1 reply1 返信メッセージ1
message1 reply2 返信メッセージ2

従来のスキーマという概念のあるRDBになれると同じカラムに異なる属性がはいるのは気持ち悪いですが、このノード同士の関係をどんどん行として追加していきます。隣り合うノードどうしを表現するので隣接関係リスト設計パターンなわけですね。

ここで message1 に room1 をもたせるかどうか悩みました。矢印の向きはroomが親、messageが子になりそうだったので、room1 -> message1 にしています。もしmessage1 に room1 をもたせると、複数の同じデータがはいり冗長になりそうだったので子から親の列はなくしておきました(例えば message2があったとき message2にroom1の行を追加すると同じ情報になる。)

のこり。

hashkey sortkey userName messageContent
room1 user1 太郎
room1 user2 花子
room1 message1 これはメッセージです
message1 user1 太郎
message1 reply1 返信メッセージ1
message1 reply2 返信メッセージ2
reply1 user2 花子
reply1 rereply1 返信1の返信メッセージ1
reply1 rereply2 返信1の返信メッセージ2
reply2 user2 花子
rereply2 user1 太郎

あとはroom1自体の情報がないので追加しておきます。

hashkey sortkey userName messageContent roomName
room1 room1 5組
room1 user1 太郎
room1 user2 花子
room1 message1 これはメッセージです
message1 user1 太郎
message1 reply1 返信メッセージ1
message1 reply2 返信メッセージ2
reply1 user2 花子
reply1 rereply1 返信1の返信メッセージ1
reply1 rereply2 返信1の返信メッセージ2
reply2 user2 花子
rereply2 user1 太郎

完成です。これで

  • room1 に参加しているユーザー
  • room1に投稿されたmessage
    が検索できます。

ただ、ベストプラクティス的に隣接関係リストつくりましたが、「room1にひもづくメッセージを返信とそのまた返信含めてほしい」ときは複数のクエリを投げることになりそうですね ^^;

今度は、どうすれば「room1にひもづくメッセージを返信とそのまた返信含めてほしい」を実現できるか考えてみます。

追記、
数カ月間運用してみてDynamodbには複合ソートキーが一番拡張しやすいなと思いました。
HK(ハッシュキー)はstring型 or number型。そのテーブルの大本のidを保持。例えばチャットアプリであればチャットページidなど
SK(ソートキー)はstring型。ここをどう設計していくかがきも。

例えば次のように一つのテーブルでコメントとお気に入り機能を実現できる。ちなみに複合ソートキーの分け目は#が良いと思う。_を最初採用していたがハッシュキーを利用していると混同してしまう可能性あり。

HK SK content favorite_commentIds
1 comment#thread作成日時#コメント作成日時 コメントだよ
1 favorite#ユーザーid {comment#thread作成日時1#コメント作成日時1, comment#thread作成日時1#コメント作成日時2}
このスクラップは2020/12/07にクローズされました
ログインするとコメントできます