Redis を使って応答時間を半分にした話

はじめに

はじめまして。 5月にFiNCに入社し、サーバーサイドの Rails エンジニアをやっている亀田と申します。

今回は、Redis を使ってチャットのパフォーマンスを改善した話について書きたいと思います。

チャットで起きていたパフォーマンス問題

FiNCアプリにはチャット機能があります。ユーザー同士のコミュニケーションにも使えますし、お得情報の配信やユーザーのサポートなどでも利用しています。

その中で、ユーザーサポートの社内オペレーション用ツールに、(業務に支障が出るレベルではないものの)表示が重いという問題が発生していました。具体的には、対象となるルームのレコードを取得するために数百ミリ秒かかっており、レスポンスを返すまでに合計で1秒前後かかっているという状況でした。

分析をしてみたところ、インデックスを使って対象となるレコードを取得した後、下記のような処理を行うため、ファイルソートした上でオフセットを指定したレコードの取得を行っているのが原因であることが分かりました。

  1. オペレーション用のステータスを条件にソートをする
  2. ステータスが同じなら最終投稿時間が最近のものから並ぶようソート
  3. 全部を1ページに表示できる数ではないので、ページングも行う

(MySQLを使っているので)インデックスヒントを使うことで改善するという方法も考えたのですが、全体のレコードの構成・数によっては今後は逆に遅くなる可能性もあります。加えて、先々のことを考えてノウハウをためる機会にもしたいと考え、Redis を使うことにしました。

Redis とは?

Redis は、インメモリのいわゆるキー・バリュー・ストア(KVS)です。下記のような特徴を持っています。

  • RDB に比べて高速
  • 一定の条件を満たしたデータを永続化(ストレージに書き出す)できる
  • いくつかのデータタイプをサポートしている(List、Hash、Set、Sorted set)

今回は、この Redis の Sorted set を使うことでパフォーマンスを改善させることにしました。

詳細は Redis の公式ドキュメント をご覧頂きたいのですが、Sorted set は、全てのvalueにスコアを付けて保存することができる「集合」です。よく使われるのが、アクセスランキングなどのランキングです。

例えば、各要素を url-rank という Sorted set に入れるとすると、下記のようなコマンドを実行します。

redis > ZADD url-rank 1 "facebook"
redis > ZADD url-rank 2 "google"
redis > ZADD url-rank 3 "finc"

数字の部分がスコアで、アクセス数などになります。そして、スコアの降順にならべて Sorted set の要素を取得したいは場合は、以下のようになります。

redis> ZREVRANGE url-rank 0 -1
1) "finc"
2) "google"
3) "facebook"

先ほどのソートに関する要件を、スコアとして表現できれば、この Sorted set の機能が使えます。

Redis の Sorted set を使った実装

まずソートに関わる部分の要件を少し詳細に書きます。

  1. ステータスは複数存在する(仮に新着、対応中、対応済みとする)
  2. ステータスは、最終的には対応済みになる
  3. 必ずステータス順に並べる
  4. ステータスが同じなら、最終投稿時間が最近のものから並べる

以上を満たす簡単な方法としては、ステータス毎に Sorted set を作成する設計が考えられます。しかし、その設計ですと、ページ内で複数ステータスの Sorted set を参照することを考慮しないといけないので、コードが非常に読みづらいものになってしまいます。

そこで、最終的に必ず対応済みになるというところをもう少し詳しく聞いたところ、同一ステータスのまま長期間滞在することは基本的に存在しないということも分かりました。

それを踏まえて、並べたい順になるようステータス毎のオフセットを計算し、一つの Sorted set で表現することにしました。

Rubyのコードで書くと、このような感じになります(実際のコードを元に少し書き換えています)。なお、Redis へのアクセスは、Redis::Objects を使っています。

class ChatRoomSortedSet
  include Redis::Objects

  sorted_set :sorted_room_ids

  def update(chat_room)
    sorted_room_ids[chat_room.id] = chat_room.last_posted_at.tv_sec - calc_sort_offset(chat_room)
  end

  private

    def calc_sort_offset(chat_room)
      if chat_room.status == '新着'
        0
      elsif chat_room.status == '対応中'
        (60 * 60 * 24 * 365) * 1
      elsif chat_room.status == '対応済み'
        (60 * 60 * 24 * 365) * 2
      end
    end
end

この update メソッドの中身は、イメージとしては下記のようなコマンドと同じです。

redis > ZADD chat_room_sorted_set #{chat_room.last_posted_at.tv_sec - calc_sort_offset(chat_room)} #{chat_room.id}

つまり、最終投稿時間の秒からオフセットを引いたものをスコアとして、チャットルームのIDをSorted set に登録しています。

Sorted set の各値は binary safe な string なので、チャットルームのデータをシリアライズして直接入れることも、レスポンス用の JSON を入れることもできますが、ルームのIDにしています。理由は、テーブル構造やレスポンスの構造を変えるときに Redis のデータまで変更が必要になり、バージョンアップなどが煩雑になるためです。

この Sorted set を使って対象となるルームを取得する場合には、

chat_room_ids = sorted_room_ids.revrange(start_index, end_index)

というようなコードを使って対象となるルームのIDリストを取得し、それを使って RDB からレコードを取得すればOKとなります。

Redis を使うことでどれだけ速くなったか

最後に、Redis を使うコードに移行したことで、どれだけレスポンスタイムの短縮になったか、ご紹介します。

だいたい 40〜60%くらいのパフォーマンス改善になっています。元々ルームのデータをRDBから取得するのに数百ミリ秒掛かっていたものが、数ミリ秒程度(ここだけ見ると100分の1以下)になったのが大きく効いています。

さいごに

FiNCでは、このように Redis などの様々なミドルウェアを組み合わせて、パフォーマンス改善など、ユーザー体験をより良くすることに取り組んでいます。

ミドルウェアをどう活用するかということも含めて Rails アプリケーションを開発したいという方は、こちらから是非ご応募ください。