🙄

販売ページのクエリチューニング その1

2024/01/09に公開

はじめに

概要

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の selectmapfind と組み合わせて、元の挙動に合うようにしました。

※厳密には ActiveRecord::Associations::CollectionProxy というオブジェクトであればキャッシュを効かせることができます。

url.payment_methods_urls.class
#=> PaymentMethodsUrl::ActiveRecord::Associations::CollectionProxy

ActiveRecord メソッドの中でもクエリ速度の速いメソッドを使用

ActiveRecord メソッドの中でクエリ速度の速いメソッドは pluckexists? が挙げられます。
idspluck(: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では、エンジニアを採用しています。
少しでも興味がありましたら、以下をご覧ください。
https://hrmos.co/pages/superstudio/jobs/0000400
https://hrmos.co/pages/superstudio/jobs/0000404
https://hrmos.co/pages/superstudio/jobs/0010025
https://hrmos.co/pages/superstudio/jobs/0000407

下記の記事は、SUPER STUDIOのキックオフイベントで表彰されたエンジニアのインタビュー記事です。
SUPER STUDIOのエンジニア組織をより理解できる内容となっておりますので、ご一読ください。
https://www.wantedly.com/companies/super-studio/post_articles/497997
https://www.wantedly.com/companies/super-studio/post_articles/487617

SUPER STUDIOテックブログ

Discussion