販売ページのクエリチューニング その1
はじめに
概要
ecforceはエンドユーザへの販売の仕組み、注文や顧客の管理を提供するシステムです。
今回はエンドユーザ向けの販売ページに関する速度改善についてまとめました。
経緯
ここ数年、エンドユーザ向けの販売ページの機能追加によって、販売ページの描画が遅くなりました。
それによって以下の問題が発生しました。
- クライアントによっては広告を打って、販売ページにアクセスが集中するケースがあり、サーバが負荷に耐えられず、購入できないエンドユーザが発生した
- 一部クライアントからクレームが上がってしまった
- インフラチームの稼働負荷が上がってしまった
上記を解消するために、社内で半年間かけて販売ページのパフォーマンスを改善しました。
原因特定
分析ツールを使って原因を探ったところ、以下の箇所で不要なSQLが発行されていることが確認できました。
- 対象の販売ページで利用可能な支払い方法の一覧を取得し、画面に展開する
- 対象の販売ページの設定値を取得し、画面に展開する
また販売ページにおける以下の描画においても改善の余地があることがわかりました。
- 画像
- CSS
- JavaScript
今回は 対象の販売ページで利用可能な支払い方法の一覧を取得し、画面に展開する
の内容を記載します。
本題
前提
まずこの機能における前提を記載します。
- 広告URL単位で販売ページを作成できる
- 広告URLごとにフォームに表示する項目を個別に設定できる
- 個別設定がないものはマスターデータの設定に従う
- ecforceで提供している支払い方法は約 50-60 ほど存在する
- 広告URLのテーブルは
urls
(model:Url
) - マスターデータのテーブルは
payment_methods
(model:PaymentMethod
) - 個別設定の中間テーブルは
payment_methods_urls
(model:PaymentMethodsUrl
)
元のコード
以下は支払い方法のプルダウンを描画するメソッドです。
マスターデータを all
で取得し、ループ内で個別設定のレコードを find_by
しています。
def payment_methods
arr = []
PaymentMethod.all.order(sequence: :asc).each do |payment_method|
case payment_methods_urls.find_by(payment_method_id: payment_method.id).try(:active)
when true
arr.push payment_method
when false
next
else
arr.push payment_method if payment_method.active?
end
end
arr
end
次に以下は、支払い方法によって必要なHTMLを描画するかしないかを分岐させるメソッドです。
先ほどの payment_methods
というメソッドを使い回す形をとっています。
以下のようなコードが6箇所ほど存在していました。
def payment_method_1_active?
payment_methods.detect{ |pm| pm.id == 1 }.present?
end
def payment_method_2_active?
payment_methods.detect{ |pm| pm.id == 2 }.present?
end
def payment_method_3_active?
payment_methods.detect{ |pm| pm.id == 3 }.present?
end
問題点
これらのコードには以下のような問題点がありました。
- メソッド
payment_methods
が、マスターデータ分だけ中間レコードのSELECTが実行されているため、N+1のような状態になっている - メソッド
payment_method_N_active?
が、N+1を含んだメソッドpayment_methods
を使い回しているため、さらにクエリ発行回数が膨れ上がっている
改善後のコード
上記を解消するためにコードを改善し、最終的には以下のようになりました。
def payment_methods
personal_active_ids =
payment_methods_urls.select(&:active?).map(&:payment_method_id)
global_active_ids =
PaymentMethod.where.not(id: personal_active_ids).where(active: true).ids
PaymentMethod.where(
id: (personal_active_ids + global_active_ids).uniq
).order(sequence: :asc).to_a
end
def payment_method_1_active?
personal = payment_methods_urls.find{ |pm| pm.payment_method_id == 1 }
if personal.present?
personal.active?
else
PaymentMethod.exists?(id: 1, active: true)
end
end
def payment_method_2_active?
personal = payment_methods_urls.find{ |pm| pm.payment_method_id == 2 }
if personal.present?
personal.active?
else
PaymentMethod.exists?(id: 2, active: true)
end
end
def payment_method_3_active?
personal = payment_methods_urls.find{ |pm| pm.payment_method_id == 3 }
if personal.present?
personal.active?
else
PaymentMethod.exists?(id: 3, active: true)
end
end
改善のポイント
コード改善をするにあたり、以下のような点を意識しました。
- Railsのアソシエーションのキャッシュを活用
- ActiveRecordメソッドの中でもクエリ速度の速いメソッドを使用
Rails のアソシエーションのキャッシュを活用
Rails のアソシエーションには、キャッシュの仕組みが存在します。
アソシエーションメソッドを単独で使用する場合と、 find_by
を組み合わせた場合を比較すると以下のようになります。
has_many :payment_methods_urls
url = Url.first
url.payment_methods_urls # SQL発行される
url.payment_methods_urls # SQL発行されない
url.payment_methods_urls # SQL発行されない
url = Url.first
url.payment_methods_urls.find_by(active: true) # SQL発行される
url.payment_methods_urls.find_by(active: true) # SQL発行される
url.payment_methods_urls.find_by(active: true) # SQL発行される
アソシエーションメソッドでキャッシュを利用できるケースは限られているので、注意が必要です。
今回のクエリ改善では、アソシエーションメソッドを単独で使用したあとに、Rubyの select
や map
や find
と組み合わせて、元の挙動に合うようにしました。
※厳密には ActiveRecord::Associations::CollectionProxy
というオブジェクトであればキャッシュを効かせることができます。
url.payment_methods_urls.class
#=> PaymentMethodsUrl::ActiveRecord::Associations::CollectionProxy
ActiveRecord メソッドの中でもクエリ速度の速いメソッドを使用
ActiveRecord メソッドの中でクエリ速度の速いメソッドは pluck
と exists?
が挙げられます。
※ ids
は pluck(:id)
と同じ意味です
pluck
は必要な列だけ抽出して、配列に変換するメソッドです。
必要な列だけに絞られるため、 all
よりもクエリ速度が向上します。
PaymentMethod.all
# SELECT * FROM payment_methods
#=> [#<PaymentMethod id: 1.....>, #<PaymentMethod id: 2.....>...]
PaymentMethod.pluck(:id)
# SELECT id FROM payment_methods
#=> [1, 2, 3, 4...]
exists?
は特定の条件で 1 件でもレコードが存在するかをbooleanで返却するメソッドです。
以下のようなクエリが発行されます。
LIMIT 1
が付与されるので、かなり速いです。
PaymentMethod.exists?(id: 2, active: true)
# SELECT 1 AS one FROM `payment_methods` WHERE id = 2 AND active = 1 LIMIT 1
まとめ
支払い方法の描画において以下を実施したことで、SQLの発行回数が 400回 → 10回
ほどに改善されました。
- Railsのアソシエーションメソッドによるキャッシュを利用
- ActiveRecordメソッドの中でもクエリ速度の速いメソッドを使用
- Rubyのブロックメソッドと組み合わせ
次回は対象の販売ページの設定値を取得し、画面に展開する際の改善の話をしていきます。
SUPER STUDIOの採用について
SUPER STUDIOでは、エンジニアを採用しています。
少しでも興味がありましたら、以下をご覧ください。
下記の記事は、SUPER STUDIOのキックオフイベントで表彰されたエンジニアのインタビュー記事です。
SUPER STUDIOのエンジニア組織をより理解できる内容となっておりますので、ご一読ください。
Discussion