いいねとその通知機能をDynamoDBで設計したら思ったよりムズい - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

いいねとその通知機能をDynamoDBで設計したら思ったよりムズい

【Unit4 ブログリレー4日目】

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 今回は、SNSではごく一般的ないいねとその通知機能をDynamoDBを利用して実装したら思ったより大変だったので、その詳細をご紹介します。

キャナルシティ劇場は、福岡県福岡市博多区の複合商業施設「キャナルシティ博多」のシアタービル最上階に位置する劇場。本文には特に関係ありません。

m3ラウンジ

初日の山田の記事でも紹介したとおり、m3ラウンジはFacebookの医師版のようなサービスです。去年の8月末くらいにリリースされて、最近まで集中して開発をしていました。

m3ラウンジのいいねとその通知の要件

m3ラウンジのいいねとその通知に対する要件は以下のとおりです。SNSでは一般的な仕様だと思います。

  • コメントに対するいいねの数を表示する
  • 過去にコメントに対していいねしていれば、すでにいいね済であることを表示する
  • 1回したいいねは取り消せるし、取り消したあとでも再びいいねできる

コメントに対するいいねの表示。いいねしていれば色がつく。

  • いいねされたらコメントの投稿者に対して誰からいいねされたか通知する
  • いいねの通知は1つのコメントに対して何個のいいねであっても未読の間は1通知にまとめる
  • いいねの通知を読んで既読になった後に同じコメントにいいねがあったら、既読の通知とは別に未読の1通知ができる

いいねの通知。いいねしたユーザが2人までならば名前を表示する。

3人以上の場合は1人目の名前と人数を表示する。

  • いいねの通知は通知を確認するまで未読のいいね数を増やす。確認したら既読となり数は0になる
  • 未読の通知は確認するまで消えない
  • 既読になった通知は一定期間は通知一覧に残し、一定期間が過ぎたら消す

未読数の表示。

  • 取り消したいいねの通知は消さずにそのままでもよい
  • いいねした人の一覧は表示できなくてよい
  • ユーザがいいねしたコメントの一覧も表示できなくてよい

RDBで実装したらどうなるか

使い慣れたRDBであれば、特に深く考えることもなく容易に設計できると思います。

いいね機能

いいねを記録するテーブルとして、コメントID, いいねした人のユーザIDをカラムとするテーブルを構築するのが自然です。 いいね1つにつき1レコードを記録し、いいね取り消しでレコードを削除すればいいだけです。 同じ人が何回も同じコメントにはいいねできないようにunique制約も設定します。

CREATE TABLE comments_favorite (
  comment_id uuid PRIMARY KEY,
  user_id uuid NOT NULL,
  unique(comment_id, user_id)
);

このRDBテーブルでそれなりに機能するとは思いますが、レコード数に懸念があります。 このテーブルのレコード数のオーダーは、コメント数×ユーザ数の規模であり、コメント数やユーザ数より速いスピードで増えます。 テーブルが大きくなればなるほどinsertの性能も、検索や集計の性能も落ちますので、 コメントやユーザのテーブルよりも早い段階で、このいいねのテーブルが全体のボトルネックになるのは確実です。

特に、テーブル正規化のため、いいね数を毎回group byによるcountで集計する方式で算出していると、 コメントの表示のたびに集計が必要になるので、コメントの表示も遅くなっていきます。 非正規化して、いいね数は別テーブルに記録すれば集計の遅さは解消できますが、いいねのinsert/deleteの性能が落ちるのは変わりません。

通知機能

通知はもう少し複雑で、例えば未読の通知と既読の通知(一定期間残す)を分けて記録する構成が考えられます。 未読通知テーブルは、通知ID、ユーザID(通知したい人)、コメントID、いいねしたユーザのIDのリストをカラムとし、 既読通知テーブルは、通知ID、ユーザID(通知したい人)、コメントID、いいねしたユーザのIDのリスト、未読から既読になった日時をカラムとしたテーブルとします。 いいねした人の名前を通知に表示するため、いいねしたユーザのIDのリストも合わせて記録する必要があります。

いいねしたユーザのIDのリストを1カラムに詰め込むのは微妙なので、いいねしたユーザのIDを、通知IDをキーとして別のテーブルで管理する構成もありです。 ただし、未読通知のリストを取得する場合に、未読の通知の数だけこの別テーブルへのクエリが発生するので、分けた場合はクエリが増えますし、いいねしたユーザのIDのリストを検索などの条件にはしないので、リストのままでいいと思います。

CREATE TABLE notification_fav_to_my_comment_unread (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL,
  comment_id uuid NOT NULL,
  fav_user_ids uuid[] NOT NULL,
  unique(user_id, comment_id)
);
CREATE INDEX ON notification_fav_to_my_comment_unread (user_id);

CREATE TABLE notification_fav_to_my_comment_read (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL,
  comment_id uuid NOT NULL,
  fav_user_ids uuid[] NOT NULL,
  insert_timestamp TIMESTAMP WITH TIME ZONE NOT NULL
);

CREATE INDEX ON notification_fav_to_my_comment_read (user_id);

いいね時には、いいねテーブルへの記録と同時に、未読通知テーブルにもレコードを追加します。 もし、すでに別の人がいいねしている未読通知がある場合はレコードが存在しているので、 いいねしたユーザのIDのリストに新たにいいねした人のユーザIDを追加して更新します。

未読の通知が読まれたら、未読通知テーブルから既読通知テーブルに移し、未読通知テーブルからはレコードを削除します。 また、既読になった通知は一定期間で消したいので、古い既読通知はバッチ実行などにより削除する処理が必要です。

通知に関しても、いいねテーブルほどではないですが、コメント数×ユーザ数規模のテーブルが必要(細かく既読をつけると、既読通知は1コメントにつき複数レコード存在しえる)になり、いいねテーブルと同じ課題があります。 さらに古い既読通知を削除するバッチの管理も必要になります。

DynamoDBで実装する

先日の榎田の記事にもあったように、RDBでこのような通知を実装すると、 サービス開始直後には問題にならなくても、ある程度サービスが成長してレコード数が増えた頃に性能問題が発覚する経験が何度かありました。 m3ラウンジではこの問題を避けるためDynamoDBを利用することにしました。

DynamoDBを採用したのは、

  • 適切に設計すればレコード数が増えても性能が落ちづらい
  • TTLを設定すれば勝手にレコードを消してくれる機能でお掃除バッチが不要
  • DynamoDBを使いこなしたい(大事)

などが理由です。

いいね機能

いいねを記録するテーブルとして、PK(partition key)としてコメントID、SK(sort key)としていいねした人のユーザIDをキーとする いいねテーブルを用意しました。他にattributeは記録しません。 複合プラマイマリキー(PK+SK)で一意になる必要があるため、同じコメントに同じ人が複数回いいねすることはDBの制約としてできません。

いいねテーブル

この構成では、コメントIDごとにpartitionが分けられるので、コメント数が増えてもいいねレコードの追加に対する性能は落ちません。 DynamoDBでは1partitionの最大サイズは10GBとのことなので、コメントID、ユーザIDがそれぞれ36バイト(文字列のUUID)なので、 約1億5千万いいねは記録できます。日本の医師が全員いいねしても余裕です。

m3ラウンジでは、今のところ必要ありませんが、ある人がいいねしたコメントの一覧を取得する必要がある時は、GSI(global secondary index)として、 PKにいいねした人のユーザID、SKにコメントIDを設定する必要があります。

このテーブルでコメントID(PK)を指定すれば、いいねした人のユーザIDの一覧を取得できるので、その数を数えればいいね数は算出できますが、 RDBの場合と同じく、コメントの表示の度に数えるのは効率が悪いので、いいね数は別のテーブルに記録します。

いいね数テーブル

いいね数テーブルはPKとしてコメントIDをキーとするテーブルで、SKはありません。attributeにいいね数を記録します。 SKがないので、PKがプライマリキーとなり、同じコメントに対するレコードが2つできることはありません。 こちらもコメントIDごとにパーティションが分かれるので、コメント数が増えても更新の性能は落ちません。

通知機能

通知はRDBの場合と同じく、未読と既読に分けます。

未読の通知テーブルは、PKとしてユーザID(通知したい人)、SKとしてコメントID、 attributeにいいねしたユーザのIDのリスト、最終更新日時、通知に含まれるいいねした人の数(=リストの要素数)を記録します。 LSI(local secondary index)として、PKにユーザID、SKに通知に含まれるいいねした人の数を指定します。 未読の通知は、常に1コメントに対し1レコードであるので、コメントが投稿された時点でレコードは作成しておきます。 誰もいいねをしていない状況では、いいねしたユーザのIDのリストは空、いいねした人の数は0です。

未読の通知テーブル

既読の通知テーブルは、PKとしてユーザID(通知したい人)、SKとして既読になった日時、 attributeにコメントID、いいねしたユーザのIDのリスト、通知が消える日時(UNIX時間)を記録します。 通知が消える日時のattributeをこのテーブルのTTLとして指定すると、DynamoDBは指定した日時以降にレコードを消してくれます*1。 ユーザID(PK)と既読になった日時(SK)でプライマリキーとなるので、同じユーザの別のコメントで同時に既読になるとユニーク制約でレコードを記録できなくなってしまいますが、 ナノ秒レベルで時刻を記録しているのでその可能性は低いだろうということで既読になった日時をSKにしています。 同じユーザの同じコメントでも、既読になったタイミングによって、複数の通知がありえるので、未読の通知テーブルと同じようにコメントIDをSKにすることはできません。

既読の通知テーブル

さらにいいねと同じく、未読の通知のいいね数は、未読の通知テーブルのいいね数を合計するのではなく、未読の通知数テーブルに数を記録します。 未読の通知数テーブルは、PKとしてユーザID(通知したい人)、SKとしてコメントID、attributeにいいね数を記録します。 またLSIとして、PKにユーザID、SKにいいね数を指定します。

未読の通知数テーブル

いいねする

ユーザがあるコメントに対していいねする際には以下の処理を実行します。

  1. いいねしているかチェック
    • いいねテーブルでコメントIDとユーザIDを指定してレコードがあるかチェック
  2. いいねしていなければ、いいねを記録
    • いいねテーブルでコメントIDとユーザIDを指定してレコードを追加
  3. いいね数を増やす
    • いいね数テーブルでコメントIDを指定していいね数を1増やす
  4. 未読通知を更新
    • 未読通知テーブルで、指定のコメントの最終更新日といいね数といいねしたユーザIDのリストを更新する
  5. 未読通知のいいね数を増やす
    • 未読の通知数テーブルで、指定のコメントのいいね数を1増やす

このうち、2〜5の処理はアトミックである必要があります。いいねを記録したけどいいね数を増やさないとデータに矛盾が生じます。 したがって、2〜5の処理は実際には1つのトランザクションで実行されるように、TransactWriteItems APIにてまとめて実行します。

docs.aws.amazon.com

golang の []*dynamodb.TransactWriteItem で表現すると以下のようになります。

[
// いいねを記録
{
  Put: {
    Item: {
      userId: {S: "いいねするユーザのID"},
      commentId: {S: "コメントID"}
    },
    TableName: "comments-favorite"
  }
}

// countを1増やす。attributeにcountがない場合は失敗させる。
{
  Update: {
    ConditionExpression: "attribute_exists (#0)",
    ExpressionAttributeNames: {#0: "count"},
    ExpressionAttributeValues: {:0: {N: "1"}},
    Key: {commentId: {S: "コメントID"}},
    TableName: "comments-favorite-count",
    UpdateExpression: "SET #0 = #0 + :0\n"
  }
}

// 最終更新日時、いいねしている人の数、いいねしたユーザIDのリストを更新
{
  Update: {
    ExpressionAttributeNames: {
      #0: "lastupdate",
      #1: "numFavorite",
      #2: "favUserIds"
    },
    ExpressionAttributeValues: {
      :2: {L: [{S: "いいねするユーザのID"}]},
      :0: {S: "更新日時"},
      :1: {N: "1"}
    },
    Key: {
      userId: {S: "コメントの投稿者のユーザID"},
      commentId: {S: "コメントID"}
    },
    TableName: "notification-fav-to-my-comment-unread",
    UpdateExpression: "SET #0 = :0, #1 = #1 + :1, #2 = list_append(#2, :2)\n"
  }
}

// 未読の通知のいいね数を1増やす
{
  Update: {
    ExpressionAttributeNames: {#0: "count"},
    ExpressionAttributeValues: {:0: {N: "1"}},
    Key: {
      userId: {S: "コメントの投稿者のユーザID"},
      commentId: {S: "コメントID"}
    },
    TableName: "notification-fav-to-my-comment-unread-count",
    UpdateExpression: "SET #0 = #0 + :0\n"
  }
}
]

いいねを取り消す

取り消しもいいねと逆の処理です。

  1. いいねしているかチェック
    • いいねテーブルでコメントIDとユーザIDを指定してレコードがあるかチェック
  2. いいねを消す
    • いいねテーブルでコメントIDとユーザIDを指定してレコードを削除
  3. いいね数を減らす
    • いいね数テーブルでコメントIDを指定していいね数を1減らす

取り消しもいいねと同じく2〜3は1つのトランザクションにまとめます。

最初の仕様に示したとおり、通知を取り消す処理は実行しません。 実行できなくはないのですが、複雑な処理を実装するコストに対して、通知を消す効果が見合わないと判断したためです。 具体的には、未読/既読のいいね通知に含まれる、いいねしたユーザのIDのリストに対する条件付き更新ができないため、transactionで処理できず、不整合が起きないような実装を自分で用意しなければいけないためです。 と書いた後で念の為確認してみたところ、リストが特定の値を含むかどうかの条件式は contains(path, operand) で書けるようなので、 やろうと思えば実装はできそうです*2

docs.aws.amazon.com

通知を表示する

未読の通知の取得

未読の通知テーブルから、通知対象者がまだ読んでいない通知のリストを取得します。

  1. 未読の通知を取得する
    • 未読の通知テーブルから通知対象者のユーザIDを指定し、いいね人数が1以上の未読通知を取得する
  2. 取得した通知を並び換える
    • 最終更新日時の新しい順に並び換える

未読の通知は、ユーザIDといいね人数をLSIとする条件で取得し、並び換えはこちらで実行するようにしています。指定したユーザIDの通知で、いいね人数1人以上かつ新しい順に表示したいので、SKとして最終更新日時といいね人数を条件として取得したいですが、両方を指定したクエリは作れないので、並べ替えはこちらで実施しています。 ただし、実際のm3ラウンジの通知には、他の種類の通知(自分のコメントに返信がついた、など)もあり、それらをまとめてソートする必要があり、コメントへのいいねだけがソートされていても意味がないので、この段階では並び換える必要はありません。

未読の通知数

未読の通知数テーブルから、通知対象者の未読数を集めて合計を未読数として取得します。

  1. 未読の通知数を取得する
    • 未読の通知数テーブルから、通知対象者のユーザIDを指定し、いいね人数が1以上のレコードを取得する
  2. 取得したレコードのいいね人数を合算する

未読の通知数テーブルは、前述したとおり、いいねが0のコメントにもレコードがあり、1人のユーザに対して投稿数と同じ数だけのレコードがあるので、 検索性能が落ちないようにユーザIDといいね人数でLSIを設定しています。

未読の通知を既読にする

未読だった通知を画面に表示したら、 既読にするコメントIDのリストがフロントから渡されるので、そのコメントIDごとに更新処理を実行します。

  1. 未読の通知レコードを更新
    • 最終更新日時を最新に、いいね人数を0に、いいねした人のリストを空にする
  2. 未読の通知数レコードを更新
    • コメントIDの未読通知数を0にする
  3. 既読の通知テーブルにレコードを追加
    • 更新前の未読通知レコードの内容をそのまま既読の通知てテーブルに追加する。同時に、現在時刻からn日後を通知が消えるTTLとして設定する。

この1〜3も分離できない処理なので、transactionで実行します。 厳密には、RDBであれば3でinsertする内容を未読通知レコードをlockして取得してからinsertすることになりますが、 仮に未読通知レコードを読んでから既読通知テーブルへのinsertの間に更新されていたとしても致命的な影響はないので、この実装にしています。 TTLを設定することで、既読になったレコードはバッチなどを用意しなくても、指定した時刻が過ぎたら勝手に消えます。

テーブル設計むずい

DynamoDBのテーブル設計ではよく言われることですが、テーブルにどのようなクエリがありうるかを事前に想定して設計する必要があります。

PKとSKに何を選ぶか

DynamoDBでは、プライマリキーの設計が重要です。 RDBのように自由に複合ユニーク制約などは設定できないため、プライマリキーで制約を作ることになります。 DynamoDBを使う側で制約を作ることも可能だと思いますが、整合性が壊れても気づくのが難しいので、できるだけDBの仕組みで制約を作るべきだと思います。 PK+SKの組を複合プライマリキーとするか、PK単独でプライマリキーとします。 例えば、いいね数テーブルでコメントIDをPK、いいね数をSKにしてしまうと、コメントID+いいね数で複合プライマリキーになるので、 同じコメントでいいね数が違うレコードが複数存在できてしまいます。したがって、いいね数はSKとして指定していません。

また、PKの値に応じてデータが保存される場所が変わり、SKでソートや検索が可能になるので、 テーブルに対してどのようなクエリがありうるかを事前に検討して設計する必要があります。 一緒に取得することが多いものをPKに指定することになると思います。m3ラウンジでは、ユーザIDかコメントIDをPKとして指定しました。 一方で、クエリに指定されるPKが一部の値に偏ると、特定のpartitionだけにクエリが集中して負荷が分散されないので、PKの選定はアクセスの分布も意識する必要があります。 m3ラウンジでは特定のコメントや特定のユーザのクエリに偏ることはないと判断したため、このような設計になっています。

PKでもSKでもないattributeに条件を指定してデータを取得できますが、DynamoDB上ではこれはscanになり、 データを全部取得したのちに指定された条件に合致するレコードだけをフィルタしているだけなので、 読み込み容量を大量に消費しますし、効率が悪いです。 検索やソートにあたる処理は基本的に、indexが使われるように設計が必要です。

LSIは途中から作れない

LSIは、パーティション内の既存のSKとは別のSKを指定する機能です。パーティション内に設定するindexなので「ローカル」です。 m3ラウンジでは、未読の通知、未読の通知数で、いいね数が0より大きいレコードだけを抽出するためにLSIを設定しました。 当初は未読0件の場合はそもそもレコードを作らない設計で進めていましたが、 この設計だといいねがあった時に、upsert(レコードがなければinsert、あればupdate)のような処理をDynamoDBでやらないといけなくなって処理が複雑になるので、変更しました。 そのため、LSIを開発の途中で作ることになりました。

しかし、LSIはテーブル作成時に同時に指定しなければならず、テーブル作成後に追加で設定できません。開発の途中で必要になったら作り直しになります。 したがって、上記のようなアクセスパターンを最初から想定してindexを設計する必要があります。 なお、GSIはあとからでも追加できます。

DynamoDBをローカルで動かして設計する

上記のとおり、DynamoDBのテーブル設計には試行錯誤が必要ですが、本物のAWS環境で、何回もテーブルを作り直すのは非効率です。 そのため、AWSが公式で用意しているjarを利用して、設計の間はローカルで作って壊して試していくのがおすすめです。docker imageを使うのが楽だと思います。

docs.aws.amazon.com

まとめ

  • いいねとその通知機能をRDBで実装するのは辛いので、DynamoDBで実装しました。
  • DynamoDBでは利用ケースを十分に精査した上でテーブル設計が必要。
  • ある程度設計が固まるまで、ローカルのDynamoDBで試行錯誤した方が便利。

We are hiring!

今回紹介したm3ラウンジを含め、m3の多様なサービスを一緒に開発してくれる仲間を募集中です。お気軽にお問い合わせください。

jobs.m3.com

*1:ただし、期限日時ちょうどに消える訳ではなく、期限日時から数日は残り続けることがあります。 https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/howitworks-ttl.html

*2:Setにしか適用できないと勘違いしていたみたい。