Muzz Socialのための耐久性のあるリアルタイムインプレッションシステムの構築方法
Muzz socialは世界最大のムスリムソーシャルネットワークであり、ユーザーが投稿、コメント、そして体験を共有できるプラットフォームを提供しています。
背景
年初に、シンプルながら効果的なインプレッションシステムを追加しました。ユーザーがリクエストしたページをインプレッションとしてカウントし、その結果をElastiCache(Valkey)に保存しました。フィードがリクエされるたびに、コメント数が大幅に変更されない限り、投稿を除外するようにしました。この変更により、機能をロールアウトした後の2ヶ月間でフィードリクエストが22%増加するという、ユーザーにとって非常にポジティブな変化をもたらしました。しかし、この方法には本質的な問題がありました。ページ全体がリクエストされたからといって、それが実際に見られたとは限らないのです。そのため、このリリース直後に、モバイルインプレッションという名前の後継システムの開発を開始しました。このシステムはほぼリアルタイムであり、イベントをドロップしたくなかったため耐久性も必要でした。これは決して容易なことではありませんでした。私たちは1分間に約150,000のイベントを処理しており、これらすべてを可能な限り迅速に処理・対応する必要があったのです。Muzz socialはイベント駆動アーキテクチャを使用しており、特定のアクションが発生するとイベントが作成され、それを活用できるため、スケーラブルで制御可能な方法で既存の機能を拡張できます。
目標
このプロジェクトでの主な優先事項の一つは、耐久性とスループットでした。Muzzでは、ネットワーキングの問題やサービスが応答できない場合にリトライメカニズムを持つことができるため、キュー(SQS)を広範囲に活用しています。さらに、Kinesisリーダーのチェックポイントシステムにより、シャードの一部が失敗した場合、シャード全体が再試行されるため、システムへのイベントの耐久性が保証されます。
もう一つの重要な目標は、モバイルクライアントバージョンとの相互運用性でした。モバイルデバイスはローリングリリースで更新されるため、ビッグバンスタイルの製品提供は行わないことにしました。さらに、負荷とスループットを推定することも困難でした。代わりに、アプリ上で機能ヘッダーを使用して、フィードをリクエストする際にバックエンドに新しいインプレッションシステムを使用するよう通知しました。これにより、複数のバージョンのインプレッションシステムを連携させることができました。さらに、機能のキルスイッチを持つことも可能になりました。
実装

これを実現するために、モバイルログを読み取り、表示されたインプレッションを探す新しいKinesisコンシューマーを作成しました。これがトリガーとなり、gRPC呼び出しを介してrelationsサービスにrelationsテーブル(グループメンバーシップ、いいね、コメント、返信を保存するために使用されるテーブル)にレコードを追加します。 relationsテーブルには、relationsテーブルの変更をリッスンするlambdaがあり、イベントを見たソーシャルプロファイルと見られた投稿、およびイベントタイプのEnumをEvent bridgeに追加できるため、SeenPostイベントが作成されます。
go code snippet start
type Entity_PostSeen struct {
SocialProfileId string `protobuf:"bytes,1,opt,name=social_profile_id,json=socialProfileId,proto3" json:"social_profile_id,omitempty"`
PostId string `protobuf:"bytes,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
SeenAt int64 `protobuf:"varint,3,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"` // Timestamp in unix epoch``
}go code snippet end
イベントが作成された後、2つのダウンストリームサービスがそれを消費します。1つ目は、フィード生成に使用するグラフデータベースにリレーションを追加する既存のNeo4jライターです。2つ目は、すでに見られた投稿を回避するために使用されるキャッシュに見られた投稿を追加するキャッシュライターです。プロジェクトの開始時に強調した問題は、保存しているデータのすべてが処理後も重要であるわけではないということでした。このため、DynamoDB Time to live (TTL)を使用して、設定された期間より古いレコードを自動的に削除しました。さらに、これにより削除イベントが作成され、ダウンストリームサービスから削除されます。
評価
システムが最適に機能しているかを評価するために、インプレッションシステムの主要コンポーネント全体でイベントを追跡できるダッシュボードを作成しました。たとえば、キャッシュとグラフが不整合になっている場合、より広範な問題を示している可能性があります。

実際、このメトリクスのおかげで、リソース不足と並行性の活用不足によるサービスの劣化を簡単に観察できました。さらなる複雑さは、同時書き込みによるものでした。10個のイベントを処理する場合を想像してください。そのうち5個は同じユーザーからのもので、それぞれにgoroutine(goroutineは軽量な実行スレッド)を生成します。これらはそれぞれダウンストリームで消費されるイベントを作成しますが、書き込みが互いに競合する可能性があります。そのため、この問題を解決するためにredisを使用した分散ロックシステムを実装する必要がありました。このソリューションでは、キャッシュがユーザーのキーが存在するかどうかを保存し、存在しない場合はロックを取得し、そうでない場合はロックを取得できるまで待機します。これにより、goroutineが互いに競合しなくなります。全体的なシステムは1日あたり約160万件のイベントを処理し、2ヶ月の運用でドロップされたメッセージはわずか2件でした。
興味があれば、投稿モデレーション に関する別のブログ記事も書いています。