パフォーマンス改善の裏側：チャットアプリのバックエンドにEDAを採用して得た知見

# はじめに

今年の2月から[Livefor](https://live4.co/)というCAMPFIREのグループ会社でコミュニケーションアプリ(チャット機能が中心)のバックエンド開発に携わっております。
今回、またまたEDA(Event Driven Architecture)を採用して得た知見を書き残したいと思います。


# EDAを採用した経緯 ～ モノリスとマイクロサービスを経て


当初のバックエンドの構成は大きく"rails api" と "その他" という感じで大きく二つのサービスで構成されていて、基本的にrails api でデータを管理しつつ、ウェブソケ関連を"その他"っという感じでまるっと分けられている感じでした。
インフラはawsでした。

![summary-old.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64758/fff1894f-3bb5-7954-b542-1456bc336560.png)

> だいぶデフォルメしてますが、実際にはlambdaがapiごとにあります。


一見シンプルで効率の良さそうに見えるこの構成ですが、ウェブソケットで提供しているメッセージ配信のapiのレスポンスが遅いという問題を抱えておりました。
ウェブソケットなのでレスポンスという概念はないのですが、クライアントの方では、メッセージの送信が成功したタイミングでuiの表示を切り替えないといけないため、そのメッセージ送信が成功したというバックエンドからのメッセージの受信が遅いうことです。



チャットのメッセージ送信のフローはこんな感じ

```mermaid
sequenceDiagram
    actor a as Alice
    participant w as web socket
    participant r as rails
    actor b as Bob
    actor e as Eve
    a->>w: ウェブソケ接続
    a->>+w: メッセージ送信！
    w->>+r: 権限チェック(認証)
    r->>r: 認証(RDB参照)
    w->>w: メッセージ登録(dynamodb)
    w->>b: メッセージ配信
    w->>e: メッセージ配信
    w->>-a: メッセージ送ったよ
```

伝統的なrest apiのように、すべての処理が直列で実装されており、このフローには以下の課題がありました。


### 全てのメッセージ配信を終えてから終了扱いになっている

「メッセージを送信！」のメッセージから、「メッセージを送ったよ」のメッセージ配信までが一つのlambdaで実装されていて、多数のIO処理が発生しており、全て終わってから完了通知の「メッセージ送ったよ」を配信されております。
実際に、相手方がオンラインの保障はなく、オンラインになったタイミングでメッセージを取得すれば良いので相手方への配信処理まで待たなくても良いはず。

### メッセージを配信する都度、認証処理を実行している

これは、api gateway やウェブソケットのプロトコルの仕様など、さまざまな制約があったのですが、とはいえ常時接続なので認証くらいは最初の一回でよいはず。

### ウェブソケに繋がってないとメッセージを送れない

これは致命的で、ユーザー入力以外のサービスからの定型メッセージの配信やAI botなどからのメッセージ配信が実質的にできないことになっており、割と緊急度の高い課題でした。


# EDAの導入

まずはじめに実行したのは責務を分離してサービスの分割です。
それまで、実装手段ベース(railsなのかそれ以外なのか)で分割されていたサービスを、機能ベースに分割し、サービス、チャット、そして通知の3つにしました。
チャットのサービスではメッセージ管理の責務を負い、通知サービスではクライアントアプリに必要なイベントを配信することに責務を追うと言う思想です。
そしてサービス間の連携はイベント駆動でつながるようにし、イベント配信にはAWS SNSを採用しました。

![summary-new.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64758/11ee4caa-3863-c80b-bc1d-8fcdb0d9528c.png)

> だいぶデフォルメしてますが、実際にはlambdaが大量にあります。


## メッセージ送信はrest apiで、配信処理はweb socketで。

通知とメッセージ管理の責務を分割したので、もうすべての通知を待つ必要もありませんし、メッセージ送信をweb socketで受け取る必要もありません。

web socket で提供していたメッセージ送信のペイロード仕様を廃止し、rest apiでメッセージを送れるようにしました。
Dynamodbに書き込まれたメッセージをDynamoDB Stream経由で受取りSNS Event に変換、通知サービスでそのeventを受取り、対象チャンネル内のユーザーにweb socketの配信処理を実行するようにしました。

これにより、メッセージ送信のapiはdynamodbへの書き込みが完了した時点で完了扱いになるので、爆速になりました。


## web socketの認証は接続時のみ

それまではweb socketのメッセージ配信の中に認証tokenを含めて、web socketのメッセージ送信の都度、そのtokenのチェックを実行されていたのですが、接続時のheaderにtokenをいれて認証する方式に変更しました。
api gatewayを利用しているので、Authorizerの機能を利用して実装しました。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html

ただ、ちょっと課題が残ってまして、apigatewayの縛りがあったり、ブラウザのweb socketの実装に縛りがあって、ブラウザだとヘッダが使えなかったりするので、クエリパラメーターでtokenを送る仕様にしちゃっていて、アクセスログにtokenが記録されるリスクがあります。

環境ごとにawsアカウントを分けていて、ログを見ることができる人かぎられてるので、運用で回避っと。
~~amplify も同じ回避手段とってるので、見逃して〜w~~

## メッセージ送信がrestなので、拡張しやすくなった。

メッセージ管理と通知で責務分解したので、任意のタイミングでバックエンドからメッセージを送れるようになりました。
LLMと連携して特定のチャンネルやイベントに反応してLLMで生成したメッセージを送るという要件も割とさくっと実装できました。


# EDAからの恩恵

この構成で運用してすぐにEDAで良かった、っと自画自賛するイベントが起きましたw


## チャット機能固有の課題

無事にサービスを公開でき、しばらく運用してユーザーが増え始めた頃、未読メッセージの計算が遅延するというチャット機能固有の問題が出てきました。

世の中のいろんなコミュニケーションアプリに、ごく普通に実装されている未読の数の表示という仕組みがいかに地球に優しくないかを思い知ることになりました。

まず、そもそも"未読"を数えるために"既読"を定義しないといけません。
どこまで読んだかをもとに未読の数を数えるのです。

当サービスではチャットの既読を以下の仕様としております。
- チャンネルを開き、メッセージをロードした時点で受信した最新メッセージは既読
- チャンネルを開いてるときにメッセージを受信したときはそのメッセージまで既読

一見シンプルなのですが、大変なのはメッセージを受信したときです。

複数人が参加してるチャンネルだと、すべての参加者ごとにそれぞれの既読に基づいて未読の数を数えないといけないです。
どこまで読んだかは人それぞれですからね。

```mermaid
sequenceDiagram
    actor a as Alice
    participant c as Chat
    actor b as Bob
    actor e as Eve
    a->>+c: メッセージ送信！
    par Bobの未読計算
        c->>c: bobの未読計算
        c->>b: 未読更新したよ
        
    and Eveの未読計算
        c->>c: Eveの未読計算
        c->>e: 未読更新したよ
    and Aliceの未読計算
        c->>c: Aliceの未読計算
        Note over c: 自分のメッセージは数えないので結果変わらない
    end
```

そしてさらにやっかいなのが、ユーザーがそのチャンネル上でオンラインだった場合、そのメッセージは受信直後に直ちに既読になり、未読の集計処理がさらに実行されます。


```mermaid
sequenceDiagram
    actor a as Alice
    participant c as Chat
    actor b as Bob
    a->>c: メッセージ送信！
    c->>b: メッセージきたよ

    par 未読計算
        c->>c: bobの未読計算
        c->>b: 未読更新したよ
    and ここ、ほぼ同時処理になる
        b->>c: 読んだし
        c->>c: 未読計算
        c->>b: 未読更新したよ
    end
```

これがオンラインユーザーの数だけ発生するのです。

ということで...
- メッセージや未読、既読を管理しているdbの負荷がバク上げ
- web socket配信の同時実行数もバク上げ
- lambdaの同時実行数もバク上げ (場合によってはawsに申請が必要)

という感じの地球とお財布に優しくない事態になりました。

## 意外にさくっと対応できた

この問題、瞬間的に未読を計算するべきイベントが大量に発生するのですが、未読の数を更新するという機能要件の性質的に、メッセージを受信したタイミングとそのメッセージを既読にしたそれぞれのタイミングで未読を再計算する必要はないので、イベントをバッファリングして十分に短い一定周期(1秒にしてます)で未読を集計することにしました。

オンラインのユーザーであれば新着メッセージの受信後のそのメッセージの既読処理までのイベントがバッファリングされるので未読の計算は1度だけすむようになるし、また、多数のユーザーからほぼ同時にメッセージを受けたときも計算処理がまびかれますので、単純に計算量を半分以下にできました。


```mermaid
sequenceDiagram
    actor a as Alice
    participant c as Chat
    actor b as Bob
    a->>c: メッセージ送信！

    par 未読計算
        c->>c: 未読更新処理イベントをバッファリング
    and
        c->>b: メッセージきたよ
        b->>c: 読んだし
        c->>c: 未読更新処理イベントをバッファリング
    end

    note over c: 一定周期
    c->>c: 未読の計算
    c->>b: 未読更新したよ
```

バッファリングの仕組みはシンプルで、それまでSNS Eventをそのまま未読更新処理のキューに入れていたのをやめて、AWS Kinesis stream にイベントをながし、ストリーム処理の中で重複レコードを間引く処理を入れました。
SQSの機能で重複を間引く機能もありますけど、それだとキューがつまらないかぎり間引いてくれず、キューのコンシューマーがめちゃくちゃ並列処理してくれるlambdaだと、今回の"一定周期で、"っていう要件が満たせないと判断しました。
([遅延キュー](https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-delay-queues.html)でどうにかなるかも？未検証です。)



before

![unread-flow-old.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64758/123391eb-fc75-223b-4284-fc8679a2ad54.png)


after
![unread-flow-new.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64758/f0c4f9ba-2166-821d-5f86-d08c63929c99.png)


実はここで一番重要なのは、ウェブソケが登場しないことだったりします。
AWS SNSへ配信するイベントは変わってないので、シンプルに更新処理だけにフォーカスして作業することができました。
もちろん、テストはめちゃくちゃ念入りにしましたけど。

---

この問題、実はぶっちゃけロンチの前から予測はできてたので、対策の構想は作ってました。
なので、ちゃちゃっとPOや開発チームに共有してほぼバックエンドだけの対応でさくっと3日くらいで対応できました。(どや顔)
めでたしめでたし。

世の中のコミュニケーションサービスがこの問題にどう取り組んでいるかはわかりませんが、あらたな最適解が得られたら取り込んでいきたいと思います。


# EDA むずいところ

サービス間でイベント連携にするか、api連携にするか、悩むケースは少なくありません。
同期的な処理が必要な場合は、どうしても伝統的なapi連携になってしまうケースがあります。
宗教戦争のような、明確な正解がない議論のような気もしますが、要件を深堀して、非同期でもいいよね？っとごりおしして、同期的な処理を可能な限り小さくすることで、なるべくイベント連携へ寄せるようにしていきたいところです。

~~非同期のイベント連携のほうがユーザー影響無しで再処理とかしやすいので、不具合がバレないことの方が多いw~~


# まとめ

EDAなので、今回のように特定箇所の最適化のための派手な構成変更も割とさくっとできました。
基本的にサービス間をすべてイベントでつないでいれば、適材適所？最適化やリファクタリングがやりやすいのでおすすめです。
マイクロサービスはオーバーエンジニアリングになりがちという批判やトレーサビリティの課題を指摘される事もよくありますが、ぶっちゃけ半分以上は慣れの問題だと思っているので、多分今後もEDAゴリ押しで行くと思いますw

---

最近、EDAの話ばっかりしてる気がする。ごめんなさい。