clusterの加速に耐えうる柔軟な通知機能を追加したはなし

はじめに

クラスター株式会社でサーバサイドエンジニアとして働いているえんじです。
今回、表題にもあるように通知機能をclusterに追加したため、そのときに工夫した点などについて書いてみようと思います。
通知機能は様々なサービスで作ることが多いと思うので、一つの事例として参考になればと思います。

通知機能の概要

まず、今回clusterに追加した通知機能がどんなものかを解説します。
今回追加したのは、ユーザーがアプリ内で特定の画面を開くことで内容を確認できる機能です。
執筆時点ではclusterのモバイルアプリを開いた際にタブの中に「通知」というタブがあり、そこをタップすると表示されるものが今回作った内容になります。

※アイコンやワールド名はマスクしてあります

この機能は「cluster内で起きた出来事を蓄積し、ユーザーが非同期的に確認できるようにする」という体験を実現するために実装されました。最近では写真フィードを見た他ユーザーからの反応やミッションの達成履歴など、さまざまな内容がこの機能で確認できるようになっています。

設計の話

今回の機能の中で主要な要件は以下の3点でした。

  • 今後追加されるさまざまな通知内容に対応できるようにしておきたい
  • 既読/未読の通知をUI上出し分けたい
  • 通知内容に応じて、通知が並びすぎないようにまとめられるようにしたい (Xの通知のような挙動)

他の細かな要件と合わせて設計した結果、最終的なDB設計は以下のようになりました。

全体的な方針として、通知に必要な内容の収集など重たい処理は書き込み時に行い、読み取り時の処理は最小限に抑えることを前提としています。
そのため、DB構造も正規化を崩してでも読み取り時にできるだけJOINが必要ないような設計になっています。
また、NoSQLなどのRDB以外の利用も検討しましたが、パフォーマンス上懸念がなくコスト的にも優位だったため、RDBを利用することにしました。

以降の項目で、主要な要件をどのように解決していったかを掘り下げながら解説させていただきます。

さまざまな通知内容に対応するための仕組み

通知はclusterでは日々追加されている様々な機能と連携する必要があるため、それぞれの通知に表示する内容を柔軟に保持する必要があります。
その柔軟性をどのように担保しているかについて解説します。

通知を保持するDBテーブルは、以下の要素で構成しています。

通知は種別とバージョンごとに独立したデータ形式を持たせています。
組み合わせ内容ごとのデータ構造を柔軟にするため、通知を生成するために必要な情報はJSONとしてDBカラムに保持するようにしました。
以下の図は、同じユーザーAに対して「イベント開催前の通知」「写真フィードの投稿にいいねされた通知」があった場合の内容の差異です。

このように、通知ごとにまったく異なる値を参照している場合でも、一つのテーブルでデータを保持できるようになっています。この構造により、今後未知の通知が増えた場合でも対応できるようになっています。

また、表示するクライアント側でも「通知種別」と「バージョン」の組み合わせごとに表示するための実装を行っています。
その上でクライアントが知らない組み合わせのレスポンスがAPIから帰ってきた場合はその通知を表示せず無視する実装となっており、新しい通知に対応していないクライアントに対してAPIから新しい通知内容をレスポンスしても予期せぬ挙動やクラッシュしない作りになっています。

未読通知の管理

UI上で出し分けるため、サーバー上でユーザーがどの通知を読んだことがあるのか判別して、クライアントに通知する必要があります。

通知については、基本的に新しいものを優先して表示する仕様となっているため、未読の通知については通知の一覧を表示したタイミングで必ず一番先頭に並ぶ挙動をとります。
そのため通知の既読管理はユーザーが通知を取得、表示したタイミングをサーバーで保持しておき、その時刻以前に送られた通知を既読として扱うようにしました。

この構成のメリットは、今後ユーザーに対して通知がどれだけ発生しても、管理のために必要なレコードが1レコードで済むという点にあります。
これにより、既読管理のために複雑なクエリを行う必要がなく、またサーバー上で未読の通知数を算出する際のクエリもシンプルになります。

デメリットとして、例えば通知の一覧を表示した際に未読の通知が大量に存在していた場合でも「既読」の通知として扱ってしまうことが挙げられます。
このパターンについては、通知で提供したいユーザー体験や行動パターンなどを加味して執筆時点では仕様として許容しています。

通知をまとめる仕組み

これはXのLike通知のように、複数人から同じ通知内容のアクションが行われた場合、一つの通知で複数人からのアクションがあったことを伝えるためのものです。
例えば、二人から写真フィードの投稿にいいねがあった場合には以下の画像のように、「〜さん他1名」と表記される一つの通知にまとめられるようになっています。

通知をまとめる仕組みの要件は以下のとおりです。

  • ある程度の期間で通知をまとめたい(1d,2h,3wなど)
  • 期間は通知ごとに異なるものを設定したい
  • 期間は後から調整できるようにしたい

これらの要件を、「group_key」という値を元に通知の情報をUPSERTすることで実現しています。
group_keyは、通知の各要素のIDと、通知をまとめる期間を表す符号で構成されています。
例として、以下の通知を元にgroup_keyを生成してみます。

この通知を1日ごとにまとめる場合のgroup_keyは以下のようになります。

もし通知をまとめる期間を変更したい場合は、まとめる期間を表す符号部分を修正すれば修正後に生成される通知から変更されます。

この仕組みのメリットは、通知をまとめる挙動についてgroup_keyにすべて集約しているため、なにか特殊なことをする必要が出た場合でもこのkeyの生成ロジックにすべて閉じ込められるという点が挙げられます。
また、通知の読み込み時に通知をまとめる挙動について意識する必要がないため、読み込み時の処理をシンプルにすることができたのも良い点でした。

その他工夫したこと

通知を追加する方法やポイントをドキュメント化

通知を追加する際のガイドラインを用意し、通知を追加する際の作業内容や「どういう場合にこの通知を利用してはいけないか」が前提知識なしでもわかるようにしました。
これにより、今回の通知機能の開発に関わっていないエンジニアやチーム外の人が作業をイメージしやすくすることで、よりスムーズに通知を利用してもらえるようにしています。

データの有効期限を設け、データ量が爆発したときにスムーズに消し込みするための対応ができるようにした

事前に「過去何ヶ月分までは保証するが、それ以上のレコードはいつか消し込む可能性がある」という合意を取ってデータ量が爆発しそうになったら消す選択肢を取れるようにしました。
また前述した通知を追加する際のガイドラインにも記載し、通知機能を利用する場合に消し込むと困るような通知では利用しないことを周知しておくことで、実際に消し込む場合の調整が不要になるようにしています。

まとめ

今回の開発をタイトルにもあるとおり「clusterの加速に耐えうる柔軟な通知機能」にするうえで、個人的に良かった点を整理してみました。

  • 正規化を崩してでも読み込みを軽くする方針で進めた
    • → 結果としてさまざまな通知への対応や通知をまとめる機能などの複雑な仕様をシンプルに解決できる仕組みにできたため、柔軟さをもたせられた
  • 通知を追加する時のポイントや注意点をドキュメント化しておいた
    • → 予期せぬ使われ方を予防したり、自チーム以外が開発する機能でも通知機能をスムーズに導入できるようになった

特にドキュメントを用意しておくことは、通知機能に限らず色々なところから呼び出す機能を作ったときにやっておいたほうがよさそうだなーという学びを今回得ました。
今のところは数カ月間トラブルなく運用されていますが、もっと長期に運用していく中で反省点などが見えてくると思うので、今後はその点をまた記事にしようと思います。