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

概要 原著者の許諾を得て翻訳・公開いたします。 英語記事: Optimize Rails performance bottleneck with Redis caching and Rack middleware 原文公開日: 2018/02/05 著者: Paweł Urbanek Rails: RedisキャッシュとRackミドルウェアでパフォーマンスを改善(翻訳) パレートの法則によると(少々誇張あり)、Railsアプリにおけるパフォーマンス問題の95%は、エンドポイントのわずか5%で発生していることになります。本記事では、シンプルなRedisキャッシュ技法とカスタムRackミドルウェアを用いて、私のRailsアプリでエンドポイントのボトルネックを500%以上改善したときの方法について解説いたします。 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.0〜100.0のすべての値が有効)なので、使われる可能性のあるクエリをすべてキャッシュするのは不可能です。(categoryのような)不連続型のパラメータを1つだけクエリで受け取るのであれば、使われる可能性のある結果をすべてキャッシュしておくことを検討してもよいでしょう。 最終的に、クエリの実行結果をパラメータなしでキャッシュしておいて、リクエストにdiscounted_by値を含んでいないクライアントにはキャッシュ版を提供することにしました。 アプリのどのエンドポイントを最適化すればよいかわからない場合は、New Relicやpgheroで調査を始めるのがよいでしょう。 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 … Continue reading Rails: RedisキャッシュとRackミドルウェアでパフォーマンスを改善(翻訳)