Pairsのコミュニティチャットを支えるBackend周りのはなし

Ren Kanai
Eureka Engineering
Published in
15 min readDec 21, 2021

この記事は「Eureka Advent Calendar 2021」22日目の記事です。

こんにちは。Eureka Backend Teamの金井です。
2022年もあと少しで、そろそろエウレカへの入社から2年が経ちます。今年はPairsのDating体験向上のための新規機能開発にひたすら向き合う一年でした。来年もタフな一年になりそうですが、引き続き気合を入れてやっていこうと思います。

今回は今年の5月にリリースされたPairsの新機能コミュニティチャットについて書いてみようと思います。満を持してリリースした本機能ですが、去年のAdvent Calendar時点ではまだ開発中だったために記事の題材として書くことはできなかったので、やっと書けるぞという気持ちです。

コミュニティチャットとは

コミュニティチャットとは、Pairsコミュニティ内で共通の話題について他の人と交流できる新機能です。従来のコミュニティは自分が興味のある趣味を表現したり同じ興味を持つ異性を探すためのいわば「意思表示」のための機能でした。コミュニティチャットではこのコミュニティ内に「トピック」と呼ばれるチャットルームを作ることができ、コミュニティに関連する話題についていろいろな人達とチャットを楽しむことができます。

コミュニティチャットでは投稿にリアクションをつけたりスタンプを送りあうことができます。特にスタンプは最近リリースされた新機能で、先日も丁度新しいスタンプが追加されたばかりなのでぜひ使ってみてください!(GETしたスタンプはマッチング後の1対1のメッセージでも使用できますよ!)

コミュニティチャットを支える技術の一端

Timeline Pagination

Pairsのトピックの投稿取得用APIには、Timeline Paginationを採用しています。Timeline Paginationとはコンテンツのタイムスタンプに基づいたPaging手法の一種で、SlackのAPIなどが採用しています。

Timeline Paginationでは、クライアントがAPIリクエストに時間範囲(oldest/latest)とlimitを指定すると、指定した時間範囲に含まれるコンテンツが取得できます。
例えばTimeline PaginationをサポートするチャットサービスのStorageに以下のような投稿があるとします。

{
"messages": [
{
"id": "1639720395.000001",
"user_id": "xxx",
"text": "はじめまして、XXXと申します!"
},
{
"id": "1639720395.000002",
"user_id": "xxx",
"text": "よろしくおねがいします!"
},
{
"id": "1639720395.000003",
"user_id": "yyy",
"text": "こんにちは、XXXさん。こちらこそよろしくおねがいします!"
},
{
"id": "1639720395.000004",
"user_id": "zzz",
"text": "XXXさん、はじめまして。"
}
]
}

messagesは1つのチャットルーム内の投稿を表し、要素のidは投稿のタイムスタンプ(micro second)であるとします。
このとき、チャットサービス投稿取得API(hogehogechat.com/messages)に対して以下のようにoldest/latest/limitを含めてリクエストを投げると、

$ curl -XGET "https://hogehogechat.com/messages?oldest=1639720395.000002&latest=1639720395.000003&limit=4"

↓のようにoldest-latest間の時刻idを持つ投稿を返すといった具合です。

{
"messages": [
{
"id": "1639720395.000002",
"user_id": "xxx",
"text": "よろしくおねがいします!"
},
{
"id": "1639720395.000003",
"user_id": "yyy",
"text": "こんにちは、XXXさん。こちらこそよろしくおねがいします!"
}
]
}

指定したlimitは4ですが、同じく指定したoldest-latest範囲内に要素は2つしかなかったため、レスポンス上のmessages要素数は2です。

oldestのみ指定した場合は古い要素から昇順にlimit件取得し、latestのみを指定した場合は降順にlimit件取得します。oldest/latest両方を指定しないパターンは基本的に許可しません(あるいはdefaultの挙動を決めたりします)。また結果に境界値を含めるかどうかの仕様はアプリケーションの要件などを鑑みて調整します。

この手法の良いところは、特定のコンテンツ(の時刻)を軸に次のコンテンツを取得できる点です。
例えばコミュニティチャットでは、↓の画像のように自分の投稿リアクションをもらうと誰にリアクションがもらえたかを一覧で確認することができる機能があります。

この一覧の要素をタップすると、リアクションをもらった投稿を中心としてチャット画面を表示することができるのですが、クライアントにローカルキャッシュがない場合該当の投稿を中心とした古い投稿・新しい投稿両方を取りに行く必要があります。

このとき、Timeline Paginationをサポートしている状態なら以下のようにして前後の投稿を取りに行くことができます。

$ curl -XGET "https://hogehogechat.com/messages?oldest=1639720395.000002&limit=10"
$ curl -XGET "https://hogehogechat.com/messages?latest=1639720395.000002&limit=10"

また普通にチャット画面を開いて投稿を見るときにも、新しい投稿を取り直すときにはoldestだけを指定したり、

$ curl -XGET "https://hogehogechat.com/messages?oldest=1639720395.000002&limit=10"

逆に古い投稿を過去にさかのぼって取得したいときにはlatestを指定して取得できます。

$ curl -XGET "https://hogehogechat.com/messages?latest=1639720395.000002&limit=10"

limit/offset方式のように、新規データが先頭に挿入された場合にPagingがずれるといったことも起こらないのも嬉しい点です。

もちろんユースケースとしてはこれだけではありませんが、チャットとTimeline Paginationとの親和性はよく、oldest/latest/limitの指定だけで様々なユースケースをカバーすることができます(逆にユースケースが1,2個のみに限られているのであれば、専用のAPIを検討します)。

Timeline Paginationの挙動に関しては弊社の@Muukiiが公開している資料も参考にしてみてください(@Muukiiさんありがとうございます:bow:)。

Storage

コミュニティチャットの投稿を管理するStorageにはDynamoDBを使用しています。

DynamoDBにはざっと思いつくだけでもmanagedでスケールする、デフォでマルチAZ分散してくれる、暗号化・TTLをサポートするetc..など様々なメリットがあり、Pairsでもお世話になっています。

スキーマ設計の観点でもDynamoDBとコミュニティチャットとは相性がよく、DynamoDBテーブルのitemはパーティションごとにソートキーでソートされるので、タイムスタンプをソートキーとしたパーティションに対してそのままTimeline Paginationを適用できます。実際にPairsではトピックごとのパーティションを設け、投稿のタイムスタンプを使用したソートキーを採用しています。そのため、クライアントのPagination用parameterをDynamoDBへのQueryにうまくマッピングできるようになっています。

余談ですが、API GatewayやAppSyncから直接DynamoDBにつなぎ込めるのでは?とも考えました。が、他の仕様の制約上従来のAPサーバーを挟む構成にしました。投稿取得処理にはユーザーごとに投稿のFiltering処理やValidation処理があり、RDSへのアクセスも必要としたからです(lambdaを挟むことも考えましたが、RDS Proxyを挟んだとしてもread処理でもwriter用DB(master)へのコネクションを食ってしまうようなのでやめました)

Notification

コミュニティチャットではサーバーからのイベント通知にAppSyncを使用しています。昨年のAdvent Calendarでも紹介しましたが、Subscriptionの仕組みを使ってSubscriberに対して非同期にイベントを送ることができるので試験的に導入してみました。これによりメッセージやリアクションの送信イベントをクライアントが受け取り、表示情報を最新化するなどしています。

AppSyncのDataSourceにDynamoDBを置くことで、イベントログを自動的に溜め込むことができます。TTLも設定できるので、長期間の保存が不要であれば自動削除することもできます。

苦労した点

パーティション別アクセスの偏りの発生

上述の通りPairsのコミュニティチャットではパーティションをトピックごとに分割していました。リリース後しばらくは問題なかったのですが、急上昇トピックと呼ばれる新機能が出てきたあたりから少し困ったことになりました。

急上昇トピックとは、今盛り上がっている(投稿が頻繁に行われている)トピックを優先的に表示する機能です。

この機能が出始めてから投稿リクエストが一部の急上昇トピック集中するようになりました。これに伴い、利用ピークの時間帯になると投稿テーブルへの一部読み込みリクエストがスロットリングするようになりました。

なぜ読み込みリクエストがスロットリングし始めたのかというと、一部のパーティションに読み込みリクエストが集中したためです。DynamoDBの各パーティションには読み込み/書き込みキャパシティユニット上限が存在し、もし一部のパーティションにアクセスの偏りが発生してしまうと上限を上回ってしまいリクエストのスロットリングが発生する可能性があります。これはキャパシティ方式(オンデマンド/プロビジョンド)関係なく発生します。一応Adaptive Capacityによる緩和が自動的に効きますが、それでもアクセスが集中し続けるといずれ上限を上回る場合があります。

今回のキー設計だと急上昇トピックが追加されたことでアクセスが一部のパーティションに偏ってしまいました。DynamoDBのキーは各パーティションへのアクセスが均等に分散されるよう設計すべきというベストプラクティスがありますが、主要なユースケースである投稿の読み書き仕様を満たすために多少の偏りは覚悟でこのような構成を取りました。

幸いスロットリングによってアプリケーションの動作に致命的な影響が出ることはありませんでしたが*1、長期的に見た場合に問題が発生する可能性があります。なのでキー構成はそのままに、スロットリングを緩和するための方法を考えてみました。

1.テーブルのパーティションをシャーディングする
パーティションキーにprefixをつけるなどして別パーティションとし、読み込み/書き込みに対するアクセスを分散します。パーティションキーの規則を定義・統一さえすれば実装は容易です。ただしQuery時に毎回シャーディングされたパーティションをマージする必要があったり、GetItem時に各シャードに一回ずつアクセスする必要が出てくるなど、読み込みにおける実装の複雑さ・速度パフォーマンスの観点で難があります(時系列など読み取りにマージ不要な分割方法もあるが、そのような場合そもそもアクセスが分散できなくなる)。

2.テーブル自体を分割する
1.とほぼ同じ。クライアントが対象テーブル自体を切り替える必要があります。

3.キャッシュレイヤーを設ける
投稿書き込みをキャッシュし、読み込みに対するDynamoDBへのアクセスを減らします。

3.1. DynamoDB Accelerator(DAX)を使用する
DAXを導入すれば、フルマネージドのキャッシュレイヤーをAWS上で追加できます。ですがDAXのセットアップが必要なのはもちろんのこと別途料金がかかるほか、アプリケーション側でAWS SDKを使用している場合DAX専用のものを使用する必要があります。またDAX SDKはAWS SDKを内部的に参照していますが、AWS SDKの最新バージョンに追従していないパターンがあります(例えばGoの場合、 `aws-sdk-go-v2` がGAだがDAX SDKではまだv1が使用されているなど)。

3.2.アプリケーションキャッシュを使用する
アプリケーションのオンメモリキャッシュや各種サービスを利用するなどして、DynamoDBへのアクセスを回避します。DAXと異なりDynamoDBのクエリをそのまま適用することはできませんが、アプリケーション実装にキャッシュ機構があればそれをそのまま使用することができます。

他にもやり方はあるかもしれませんが、今回はシンプルに3.2.のアプリケーションキャッシュのみを使用することにしました。具体的なキャッシュ戦略として、例えば以下のようなことを試みています。

  • 特定のパラメータを持つリクエストに対してキャッシュを有効化します。例えば投稿取得APIでlatest=nowのようなaliasをサポートし、現在時刻から過去にさかのぼってlimit件数分取得する操作を可能にします。現在時刻から過去の投稿を取得する操作はよく行われるので、このリクエストに対するDynamoDBの結果をキャッシュします。ユーザーのトピックに対する新たな投稿が発生したらキャッシュクリアを実施します。
  • キャッシュ上に投稿プールを作成し、そこに対して範囲指定を伴うfetchを実施します。例えばRedisであればZAdd/ZRangeのような順序付きセットのAPIを提供しています。この仕組みを使えばDynamo上データのサブセットをキャッシュに用意し、キャッシュにリクエストを満たす投稿が存在すればそこから範囲指定を行いlimit件数分取得することができます。すべてのトピックに対してキャッシュプールを作成するのは現実的ではないため、急上昇トピックの数件に限定して実施しています。

コミュニティチャットの投稿に対するキャッシュ戦略を少しずつですが試していき、徐々にですがスロットリングの数を減らすことができました。

とはいえまだ完全にスロットリングがなくなったわけではないので、今後も引き続きスロットリング対応に取り組んでいきたいと思います。

まとめ

今回はコミュニティチャットを題材に記事を書かせていただきました。今年は3 ヶ月ほどからAdvent Calendar準備の予定を入れていたにも関わらず、結局執筆がぎりぎりになってしまいました。次は半年前から準備しようと思います。

来年もどうぞよろしくお願いいたします。

1*) 単純にRetry処理を入れていたことと、最も多いときで読み取りスロットリングリクエスト数は1,300/day程度だったためと考えています。あくまで私見ですが…

--

--