Rails: RedisキャッシュとRackミドルウェアでパフォーマンスを改善(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: RedisキャッシュとRackミドルウェアでパフォーマンスを改善(翻訳)

パレートの法則によると(少々誇張あり)、Railsアプリにおけるパフォーマンス問題の95%は、エンドポイントのわずか5%で発生していることになります。本記事では、シンプルなRedisキャッシュ技法とカスタムRackミドルウェアを用いて、私のRailsアプリでエンドポイントのボトルネックを500%以上改善したときの方法について解説いたします。

Railsパフォーマンス最適化の結果

500%を上回るパフォーマンス改善

ベンチマークは、Macbook Pro 2015(RAM 16GB、2.2 GHz Intel Core i7)でSiegeを使いました。productionのデータベースをコピーしたRailsアプリをproductionモードでローカル実行し、Pumaサーバーをワーカー2つ(各16スレッド)の状態でベンチマークを実施しました。Siegeの設定は次のようにしました。

siege --time=60s --concurrent=20

達成できた改善結果を知りたい方はこの先をお読みください。

パフォーマンス最適化前のRails

最適化作業を開始する前の詳細なパフォーマンスベンチマークは次のとおりです。

Transactions:                    5489 hits
Availability:                  100.00 %
Elapsed time:                   59.47 secs
Data transferred:              868.35 MB
Response time:                   0.22 secs
Transaction rate:               92.30 trans/sec
Throughput:                     14.60 MB/sec
Concurrency:                    19.94
Successful transactions:         5489
Failed transactions:                0
Longest transaction:             0.63
Shortest transaction:            0.03

このエンドポイントは、ReactフロントエンドとiOSモバイルクライアント起動時の両方でランディングページとして使われているので、ここを最適化によさそうな候補として決定しました。また、どのユーザーがリクエストする場合でもデータが同じだったので、あるバージョンのデータをキャッシュしてこれを全ユーザーに見せることができそうでした。

1つ注意すべき点としては、このエンドポイントがオプションパラメータとしてdiscounted_byを受け取ることです。このパラメータの型は連続値(0.0100.0のすべての値が有効)なので、使われる可能性のあるクエリをすべてキャッシュするのは不可能です。(categoryのような)不連続型のパラメータを1つだけクエリで受け取るのであれば、使われる可能性のある結果をすべてキャッシュしておくことを検討してもよいでしょう。

最終的に、クエリの実行結果をパラメータなしでキャッシュしておいて、リクエストにdiscounted_by値を含んでいないクライアントにはキャッシュ版を提供することにしました。

アプリのどのエンドポイントを最適化すればよいかわからない場合は、New Relicpgheroで調査を始めるのがよいでしょう。

Active Recordの遅いクエリをRedisでキャッシュする

データベースレベルの最適化には限度があります。データセットが肥大化して、ビジネスロジックの要請上、joinされたいくつものテーブルからデータをフェッチせざるを得ない状況では、キャッシュに頼らずにSQLデータベースで望みのパフォーマンスを達成するのは難しいかもしれません。

プロジェクトでSidekiqを使っていれば、既にそこにRedisデータベースがあります。既存のインフラをそのまま使えれば、新たな依存関係(Memcachedなど)を追加するよりずっとシンプルになります。RedisはわかりやすいキーバリューストレージAPIを提供していますので、キャッシュのために特別なgemを追加する必要はありません。

Herokuにホスティングしているのであれば、おそらくRedis to Goをお使いかと思います。その場合、Redisへのダイレクトアクセスを有効にする作業はファイルを1個追加するシンプルな作業だけで住みます。

  • config/initializers/redis.rb
  require 'redis'

  $redis = Redis.new(url: ENV.fetch("REDISTOGO_URL"))

私の場合、キャッシュを30分おきに更新するのにSidekiq Cronを使っています。

  • app/jobs/cache_updater_job.rb
class CacheUpdaterJob
  include Sidekiq::Worker
  sidekiq_options queue: 'default'

  def perform
    products = Product.current_promotions(
      Product::GOOD_DISCOUNT
    ).map do |product|
      Product::Serializer.new(product).to_json
    end

    json_data = { products: products }.to_json
    $redis.set(
      Product::PROMOTIONS_CACHE_KEY,
      json_data
    )
  end
end
  • config/schedule.yml
update_promotions_cache:
   cron: "*/30 * * * *"
   class: "CacheUpdaterJob"

キャッシュを30分おきに更新する設定は、私のアプリ用です。クライアントになるべく新しいデータを提供する必要がある場合は、キャッシュを数秒おきに更新するだけでも、リクエストのたびにデータベースからデータをフェッチするよりパフォーマンスが向上します。

discounted_byパラメータを含まないキャッシュ済みリクエストを返すRailsコントローラは次のようになります。

class API::PromotionsController < API::BaseController
  def index
    if discounted_by = params[:discounted_by]
      products = Product.current_promotions(discounted_by)
      .map do |product|
        Product::Serializer.new(product).to_json
      end
      render json: { products: products }
    else
      self.content_type = "application/json"
      self.response_body = [
        $redis.get(Product::PROMOTIONS_CACHE_KEY) || ""
      ]
    end
  end
end

修正後のバージョンは元のバージョンより最大で5倍高速化します。

Transactions:              27002 hits
Availability:             100.00 %
Elapsed time:              59.30 secs
Data transferred:        4271.65 MB
Response time:              0.04 secs
Transaction rate:         455.35 trans/sec
Throughput:                72.03 MB/sec
Concurrency:               19.96
Successful transactions:   27002
Failed transactions:           0
Longest transaction:        1.21
Shortest transaction:       0.00

この修正によって、データベースにクエリを発行する必要性が排除されるだけでなく、Active Recordオブジェクトのインスタンス化が不要になるためメモリ使用量も削減されます。データのサイズにもよりますが、JSONシリアライザ自身もパフォーマンス上の重大なオーバーヘッドになっている可能性があります。

詳しい方法については、私のRailsのメモリ使用量を削減する方法に関する記事をご覧ください。

RailsのRackミドルウェアを最適化する

サーバーへの各リクエストは、以下のRailsミドルウェアをすべて通過してからアプリのコードに到達します。

use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::Callbacks
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag

デフォルトスタックを迂回して、カスタムRackミドルウェアからレスポンスを直接クライアントに返すことで、数msほど削ることができます。これを行うには、以下のRackアプリを追加します。

  • lib/cache_middleware.rb
class CacheMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    req = Rack::Request.new(env)
    cache_path = req.path == "/api/promotions.json"
    no_param = req.params["discounted_by"] == nil

    if cache_path && no_param
      [
        200,
        {"Content-Type" => "application/json"},
        [$redis.get(Product::PROMOTIONS_CACHE_KEY) || ""]
      ]
    else
      @app.call(env)
    end
  end
end

このRackアプリをミドルウェアスタックの冒頭に挿入するため、Railsを次のように設定します。

  • config/environments/production.rb
  config.middleware.insert_before 0, CacheMiddleware

これにより、リクエストの宛先が最適化済みエンドポイントであると考えられるかどうかと、オプションパラメータの値が存在しないかどうかがチェックされます。これに該当する場合、キャッシュ済みJSONをクライアントに返し、そうでない場合はリクエストをスタック上の次のミドルウェアに渡します。

この手法は少々極端なので、使うのは非常に大きい負荷が発生している場合だけにしておきましょう。また、cookie、セッション管理、ログ出力といったRackが提供する機能のほとんどが無効になってしまいます。いずれにしろ、皆さんのユースケースによってはこの方法で数msを節約すれば、気になるホスティング料金の節約につながるかもしれません。

カスタムミドルウェアを使った場合の詳細なベンチマーク結果を以下に示します。「キャッシュのみ」のバージョンと比べて、最大で20%の改善が見られます。

Transactions:               32782 hits
Availability:              100.00 %
Elapsed time:               59.94 secs
Data transferred:         5186.03 MB
Response time:               0.01 secs
Transaction rate:          546.91 trans/sec
Throughput:                 86.52 MB/sec
Concurrency:                 19.97
Successful transactions:     32782
Failed transactions:             0
Longest transaction:          0.16
Shortest transaction:         0.00

最後に

本記事では簡単のため、キャッシュの自動無効化やRails組み込みのキャッシュサポートといった高度な手法については扱いませんでした。ご関心がおありの方はredis-rails gemや公式のRailsガイドをご覧ください。本記事のベンチマークはローカルで実行されたものであり、ネットワークのオーバーヘッドは含まれていませんので、ご了承ください。500%の改善は、私がアプリ固有のコードで達成したものです。

関連記事

[Rails] rack-attack gemでRack middlewareレベルのアクセス制限を実施する

Rails: メモリ使用量を制限してHerokuのR14エラー修正&費用を節約した話(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ