概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: 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%以上改善したときの方法について解説いたします。
ベンチマークは、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 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%の改善は、私がアプリ固有のコードで達成したものです。