🔌

WebSocketを用いた一貫性のあるリアルタイム通信

2024/03/01に公開

リアルタイム通信について

以前リアルタイム通信についてまとめた記事を執筆しました。この背景として、他ユーザの予約がリアルタイムに反映される予約管理アプリの開発があり、予約のトランザクションエラーを防ぐための一貫性を第一としたリアルタイム通信が必要でした。
https://zenn.dev/sdb_blog/articles/8558a2f226495b
しかし、PollingやLong Pollingによる通信はリクエスト間隔や更新間隔に依存するため、その間に他ユーザの予約が挟まるとトランザクションエラーを引き起こします。何より毎秒何件も予約が発生するようなアプリケーションではないことから、無駄なHTTP通信が発生するため負荷の面の課題が大きいです。
一方、SSEはchunkを用いるためコネクションは最低限で済みます。しかし、コネクションが一時的に切断された場合、その間のデータ更新を追う機能はプロトコルレベルではないため、SSEの再接続に対応するための実装コストが増えます。また、同様にHTTP通信をし続けるため、CPUのパフォーマンスは下がります。
今回のアプリケーションは同時に接続するユーザ数の増加を考慮するため、なるべく無駄なHTTP通信を削減することを目指します。

WebSocket

今まで紹介したHTTP通信は単方向なリアルタイム通信を実現しました。対してWebSocketは双方向なリアルタイム通信を実現します。具体的な通信の仕組みについては割愛しますが、WebSocketは通信の接続(ハンドシェイク)をHTTP、通信のやり取りをTCPレベルの独自プロトコルで行う通信です。
Alt text
WebSocketは最初の接続こそHTTP通信を使用しますが、通信が確立したあとは数byte程度のWebSocketヘッダを含むデータフレームに切り替わります。データフレームの仕様詳細は以下を参考にしてください。WebSocketはTCPベースであり、一度通信が確立した後はオープンな状態を保つためフルデュプレックスな通信を提供します。これによりWebSocketは双方向な通信を実現します。
https://triple-underscore.github.io/RFC6455-ja.html

利点

  • 双方向なリアルタイム通信
  • オーバーヘッドの削減
  • リアルタイム性の向上
  • ブラウザの同時接続数がHTTPより多い

欠点

  • 独自プロトコルによる実装コスト
  • セキュリティ
  • Head-of-Line Blockingによるデータ遅延
  • ブラウザやプロキシとの互換性

Pusher

WebSocketは双方向かつリアルタイム性に長けた双方向通信であるため、ユーザからのアクションが多いメッセージングサービスに向いています。一方、独自プロトコルによる複雑性から様々な問題もあります。特に、データフレームの独自実装やセキュリティ保護は従来のHTTP通信と比較して開発コストが非常に高いです。PusherはWebSocketによる技術的な開発コストの問題を解決する外部サービスの1つです。
https://pusher.com/
結論から書きますと、今回は一貫性を第一としたリアルタイム通信を実現するためにWebSocketを利用したPusherを選定しました。今回の開発で注意する点は以下の2つです。

  1. データベースが更新されたら必ず非同期にページを反映
  2. データベースの更新頻度は少ないが同時接続数は大きいため無駄な通信を削減
  3. 開発コストを下げる

WebSocketの欠点のうち「Head-of-Line Blockingによるデータ遅延」とは、WebSocketがデータフレームを送信した順に処理を行うためパケットの大きさや量に応じて他のデータ処理が遅れてしまう問題です。しかし、遅延が発生してもロストせずにデータを処理するため、今回の場合利点として捉えられることができます。通信の削減に関しても、WebSocketを使用すればデータ更新の監視のための無駄なHTTP通信を減らすことができます。さらに、WebSocket実装のボトルネックである開発コストの高さ、セキュリティ、インフラ管理は外部サービスであるPusherを利用することで解決できます。
以上の理由からWebSocketをサーバプッシュな単方向リアルタイム通信に使用しました。

単方向通信にWebSocketを使う工夫

WebSocketは軽量なデータフレームである利点を活用するため、一度に送信するデータは少なくするべきです。実際、Pusherでは10KBを超えるデータ量を送信できないようです。そのため、今回はデータベースの更新のみを通知するためだけにWebSocketを利用することにしました。
Alt text

  1. データベースの変更をPusherに通知
  2. Pusherのbroadcast機能でクライアントに通知イベントを一斉配信
  3. 通知イベントをトリガーにHTTPリクエストを行い、必要なデータを受け取る

結局、非同期な通信はHTTPで行うためLong Pollingと比較して、HTTP通信数は変わらないように見えます。しかし、今回はデータ更新の追跡をWebSocketで行なっているため、HTTPコネクションを無駄に張ることはありません。つまり、データ更新間隔に依存したHTTPリクエストを実現しました。これにより、Pollingほど無駄にHTTPリクエストを投げない&Long Pollingほど無駄にHTTPコネクションを貼らない通信ができるようになりました。この通信の肝であるイベントのブロードキャストはPublish/Subscribe型の通信モデルを利用しています。

最後に

もしWebSocketに明るくAPI開発に自信があれば、このようなアーキテクチャを取らずともWebSocketで通信を完結しても良いかもしれません。しかし、リアルタイム性、規模、開発コストを考えた場合、必ずWebSocketがベストプラクティスになるとは限りません。WebSocketを調べると「リアルタイム通信はWebSocketがいい」という記事をよく見かけますが、WebSocketにもデメリットはもちろんあります。WebSocketだけではなく、WebRTCやWebTransportにも言及しているわかりやすい記事があったため共有します。
次は、上のアーキテクチャを実際にLaravel + Vue3で実装したものをまとめようと思います。

https://www.publickey1.jp/blog/24/httphttpip.html
https://qiita.com/yuki_uchida/items/d9de148bb2ee418563cf
https://postd.cc/websockets-caution-required/

ソーシャルデータバンク テックブログ

Discussion