🙆

RailsアプリにRedisを導入し、クエリ結果をキャッシュすることで処理時間を97%削減

2025/02/05に公開

こんにちは。大学生エンジニアの豆太郎です。

本日は、RailsにRedisを導入し、クエリ結果をキャッシュすることでアプリのパフォーマンスを改善した話について書こうと思います。

キャッシュとは?

キャッシュとは、リクエストやレスポンスで送受信したデータを、メモリ上に保存しておき、次回に同じような通信を行ったときに保存したデータを活用することを指します。

このようにキャッシュの仕組みを活かして、データの再利用をすることで、通信量や回数の削減を行うことができ、通信の処理を高速化したり、サーバの負荷を削減することができます。

ここで、キャッシュが読み取ることができるデータとしては以下の例があげられます。

  • SQLで発行されたクエリ(クエリキャッシュ)
  • 静的なWebページ,HTMLファイル
  • 画像
  • 動画

参考:

Redisとは?

Redisとは、メモリ上で管理することができるキーバリューストア型のデータベースです。
Redisを使って頻繁にアクセスされるデータをメモリ上に保存しておくと、次回以降にデータベースに問い合わせることが無く、Redisに保存したデータを高速に取り出すことができ、応答時間の高速化やデータベースサーバの負荷を軽減することができます。

RDB(MySQL,PostgreSQL等)とRedisの違い

主にWebアプリケーションを作るときに使われているRDBと比較した時に、Redisでの処理が高速化される理由は以下があげられます。

1.Redisはメモリ上に保存している

MySQLやPostgreSQLなどのデータベースは、ほとんどがデータを以下の画像のディスクに保存をします。

ディスク(HDD)へのデータの読み込みでは以下の動作が行われます。

  1. アームを移動してヘッド(上記の画像のとがった先端部分)を円盤のディスク上の対象の位置に動かす。
  2. 対象のセクタ(1セクタに512バイトが保存されている)がヘッドの下に来るのを待つ
  3. 先頭のセクタから指定した数のセクタを読みこむことでデータの読み込みを行う。

上記のようにディスクへの読み込みや書き込みでは、かなりの工程が必要であり、ディスクに書き込みを行うデータベースシステム(MySQL,Postgres)は処理が遅くなっています。

それに対して、Redisは以下の画像にあるメインメモリ上にデータを保存します。

メインメモリ(RAM)はディスクに比べて、容量が小さいものの、高速な読み書きが可能です。ですので、頻繁にアクセスするデータなどをメモリにデータを保存しておくことで、ディスクに書き込み・読み込みを行う時に比べて処理速度を高速化することができます。

ただし、メモリの欠点としては、電源が消えるとデータが失われることです。このため、RedisではRDBのように永続的にデータを残しておきたい場合には向かずに、データを残す必要がないが一度読み取ったデータを再度高速に通信させたい場合に向きます。

Redisではメモリを使っており、電源が消えるとデータが消えるために保存したデータを残しておきたい場合は以下の方法が考えられます。

上記のようにメモリに保存したデータのバックアップを取ることで、運用しているRedisサーバが落ちたり、メモリのクラッシュが起きてもデータを失うことなく、信頼性を保つことができます。

2.シングルスレッドであるため待ち時間が発生しない。

RDBと比較した時にRedisの処理が高速化される理由の2つ目として、シングルスレッドであり、待ち時間が発生しないということがあげられます。

一般的なRDBでは、主にマルチスレッドを使用しており、データの挿入の際にデータの不整合が起きないように、そのデータの格納場所にロックをかけたりします。ロックをかけることによって、一つの操作しかアクセスを制限することで、データの不整合が起きないというメリットがあるのですが、同時にアクセスがあった場合に待ち時間が発生します。

しかし、Redisではシングルスレッドを採用しており、複数のアクセスが行われずに、順番に処理を行っていくため、待ち時間が発生しません。また、Redisは、

といったことで、実際にAmazonで使われているElasticCache for Redisでは、

1 秒あたり数百万リクエストに対してミリ秒以下の応答時間を提供します。

引用:Amazon ElastiCache for Redis

とのことで、一秒間に数百万のリクエストに対応することができます。

そのため、シングルスレッドであっても、一つの処理にかかる時間が少なく、処理を高速化することができます。

RailsにRedisを導入する方法

ここからは、RailsにRedisを導入する方法を紹介します。

以下のブログを参考にしました。

開発環境

バックエンド

  • Ruby(3.1.2)
  • RailsAPI(7.0.4)
データベース
  • MySQL(8.0.32)

インフラ

  • Docker(24.0.5)
  • Ubuntu(22.04.3 LTS)

導入の流れ

1.docker-compose.ymlにredisを追加

以下のように、redisのサービスを追加します。また、RailsのサービスにRedisのサービスとの依存関係を追記し、環境変数も登録します。

docker-compose.yml
version: '3'
services:
...
+  redis:
+    image: redis:latest
+    ports:
+      - 6379:6379
+    volumes:
+      - ./redis:/data
+    command: redis-server --appendonly yes
  rails:
    build:
      context: ./rails
    command: bash -c "tail -f log/development.log"
    volumes:
      - ./rails:/myapp
    ports:
      - 3000:3000
    depends_on:
      - db
+      - redis
    tty: true
    stdin_open: true
+    environment:
+      REDIS_URL: redis://redis:6379/0
+      REDIS_PORT: 6379
  next:
...


なお、docker-compose.ymlファイルを編集したため、
以下のように再度buildコマンドを打って、Redisコンテナを立ち上げます。

docker compose build

2.gemをGemfileにインストールする

Gemfile
gem 'redis'
gem 'hiredis-client'

上記の追加後にbundle installを打ち込みます。

3.各環境のキャッシュを有効にする

Redisを使う本番または開発環境の設定ファイルでキャッシュを有効にします。また、キャッシュストア(キャッシュしたデータの保存先)をRedisに変更します。

config/environments/*.rb
...
    config.action_controller.perform_caching = true

    config.cache_store = :redis_cache_store, {
      url: ENV['REDIS_URL'], # Redisの接続情報を環境変数から取得する
      expires_in: 1.hour,    # キャッシュの有効期限を設定
      driver: :hiredis       # hiredisドライバを使用することで高速化できる
    }
...

上記を追記することで、キャッシュストアとしてRedisが使用可能になります。

4.Redisを使ってクエリ結果をキャッシュする処理の実装

Redisを使った特定のクエリ結果のキャッシュを行う方法としては、Rails.chche.fetchメソッドを使用したやり方があります。

このメソッドを使うことでキャッシュからの読み取りとキャッシュへの書き込みの両方を行うことができます。

今回は、私が個人開発で開発した「お経クラウド」というアプリの一覧画面で実装されているお経のデータの一覧を取得するメソッドに、Redisを追加したいと思います。以下の画像は一覧画面を表していて、4つのお経の情報が表示されています。

このお経のデータの一覧でRedisを導入する理由としては以下があげられます。

  • データ量が多いが頻繁にアクセスされる。
    お経の一覧データは、ホーム画面に存在してかなり頻繁にアクセスされるが、データ量が多く、またお経が読まれる宗派やアップロードしている動画の取得(ActiveStorage)などの影響でクエリ発行数が多く、読み込みに時間がかかります。

  • 変更されるデータがほとんど無いため、キャッシュで数時間読み込んでも、影響がない。

rails/app/controllers/api/v1/okyo_controller.rb
class Api::V1::OkyoController < ApplicationController
  def index
    @published_okyos = published_okyos
    render json: @published_okyos, each_serializer: OkyoSerializer, status: :ok
  end

  private
    def published_okyos
-      Okyo.includes(:sects).select { |okyo| okyo.published }.to_a
+      Rails.cache.fetch("okyos", expires_in: 1.hour) do
+        Okyo.includes(:sects).select { |okyo| okyo.published }.to_a
+      end
    end
end

上記のように元々あった一覧取得のメソッドを、Rails.cache.fetchメソッドで囲みます。上記の変更によって、キャッシュストアにkeyを"okyo",valueを一覧のデータ、exipires_inを保存期限としてデータが保存されます。

実行結果

Rails.cache.fetchメソッドを実装した結果が以下のようになっています。

変更前の実行結果

Started GET "/api/v1/okyo" for 172.18.0.1 at 2025-02-05 10:51:22 +0000
  ActiveRecord::SchemaMigration Pluck (0.5ms)  SELECT `schema_migrations`.`version` FROM `schema_migrations` ORDER BY `schema_migrations`.`version` ASC
Processing by Api::V1::OkyoController#index as HTML
  Okyo Load (1.2ms)  SELECT `okyos`.* FROM `okyos`
  ↳ app/controllers/api/v1/okyo_controller.rb:15:in `cache_published_okyos'
  OkyoSectGroup Load (0.4ms)  SELECT `okyo_sect_groups`.* FROM `okyo_sect_groups` WHERE `okyo_sect_groups`.`okyo_id` IN (1, 2, 3, 4, 5)
  ↳ app/controllers/api/v1/okyo_controller.rb:15:in `cache_published_okyos'
  Sect Load (0.8ms)  SELECT `sects`.* FROM `sects` WHERE `sects`.`id` IN (3, 2)
  ↳ app/controllers/api/v1/okyo_controller.rb:15:in `cache_published_okyos'
[active_model_serializers] Rendered ActiveModel::Serializer::CollectionSerializer with ActiveModelSerializers::Adapter::Attributes (2.03ms)
Completed 200 OK in 191ms (Views: 5.7ms | ActiveRecord: 16.0ms | Allocations: 25313)

変更前の実行結果ではokyos(お経)だけでなく、ActiveStorageやSect(宗派)などのSELECT文が発行されています。また、リクエストの処理全体には191msかかっており、そのうちデータベース周りの処理には16msかかっています。

なお、すでにRails.cache.fetchメソッドを使って、Redisでお経の一覧画面の一覧を取得するメソッドをメモリ上にキャッシュして、再度お経の一覧画面の一覧を取得するメソッドを実行したところ、以下の結果がでました。

Started GET "/api/v1/okyo" for 172.18.0.1 at 2025-02-05 10:54:06 +0000
Processing by Api::V1::OkyoController#index as HTML
[active_model_serializers] Rendered ActiveModel::Serializer::CollectionSerializer with ActiveModelSerializers::Adapter::Attributes (1.5ms)
Completed 200 OK in 5ms (Views: 3.0ms | ActiveRecord: 0.0ms | Allocations: 1132)

上記では、SQL文が一度も発行されていません。これは、すでにメモリにキャッシュしてある場合に、メモリからデータが呼び出され、従来は呼び出されていたデータベースへの問い合わせが発生しないからです。

引用:Redisとシステム設計におけるその役割_geeks_for_geeks

上記の画像のようにWebサーバがRedisを使ってキャッシュを保存する仕組みはは以下の手順の通りです。

  1. Webサーバーからのリクエスト: Webサーバーは、クライアントからのリクエストに応じて必要なデータを要求します。
  2. Redisへの問い合わせ: Webサーバーは、まずRedisに要求されたデータが存在するかどうかを問い合わせます。
  3. キャッシュヒット: Redisにデータが存在する場合(キャッシュヒット)、Redisは高速にデータをWebサーバーに返します。
  4. キャッシュミス: Redisにデータが存在しない場合(キャッシュミス)、Webサーバーはデータベースサーバーにデータを要求します。
  5. データベースサーバーからの応答: データベースサーバーは、要求されたデータをWebサーバーに返します。
  6. .Redisへのキャッシュ: Webサーバーは、データベースサーバーから受け取ったデータをRedisにキャッシュします。
  7. Webサーバーからクライアントへの応答: Webサーバーは、クライアントに要求されたデータを返します。

上記のように、Webサーバは要求されたデータがRedisに存在するかをを確認して、キャッシュヒットが起こると、Redisが高速でWebサーバにデータを返すことができます。

また、リクエストの処理時間に関しては、Redisを未実装の場合は191msだったところが、Redisでのメモリのキャッシュ実装後は5msとなっており、約97%の処理時間を削減することができました!

終わりに

今回は、RailsのRedisでの実装方法を紹介しました。

Redisを導入することで

  • Redisを使ってクエリ結果をメモリ上を保存し、再度問い合わせがあった時にメモリに保存されたクエリ結果を返すことで、従来のデータベースシステムがディスクに問い合わた結果を返すよりも、処理が高速化され、データベースサーバの負荷が軽減される。

といったことが分かりました。しかし、アプリ上でRedisを使ってデータを保存・読み込みする場合には以下のことに注意が必要です。

  • Redisのデータの保存先であるメモリは、サーバが落ちるとデータが消失するため、大事なデータはディスクにバックアップを取ったり、複数台のRedisを立ち上げたりして、障害に備える必要がある。
  • データが更新されても、有効期限を過ぎるまではキャッシュされた古いデータが返される。そのため、値の登録・更新が頻繁に行われるリアルタイム制のあるデータ(ライブ配信、SNSの投稿など)には向かない。

今回は、一つのRedisサーバしか立ち上げずに、メモリオーバや膨大なアクセスへの対応ができていなかったため、今後は複数台のRedisサーバの立ち上げに挑戦し、なおかつシャーディングを使って、ランダムにデータを登録して負荷分散をしたいと思いました。

以上、読んでいただきありがとうございました。

Discussion