Ruby / Rails のパフォーマンスTips3選
はじめに
この記事はSmartHR Advent Calendar 2024シリーズ2の20日目です。
SmartHRでプロダクトエンジニアをしている @kazukun です。
2024年はHRアナリティクスという分析機能の開発に従事しました。
その際、主にパフォーマンスまわりで実装に気をつけたポイントをおさらいします。
前提条件
開発用PC(MacBook Pro)で以下の環境を用意しています。
- Ruby: 3.3.3
- Rails: 7.1.3.4
- PostgreSQL: 15
本記事で使用するテーブル群です。
各テーブルのレコード数です。
User.count
# => 100000
Department.count
# => 5175
UserDepartment.count
# => 100000
UserCustomFieldTemplate.count
# => 4
UserCustomField.count
# => 400000
ベンチマークやクエリ発行回数の取得のために、以下のメソッドを使用します。
# ベンチマーク取得用
def measure_benchmark(**tasks)
Benchmark.bm do |x|
tasks.each do |task_name, task_proc|
x.report(task_name) { task_proc.call }
end
end
end
# クエリ発行回数取得用
def count_queries(&block)
query_count = 0
ActiveSupport::Notifications.subscribed(lambda { |*args|
payload = args.last
query_count += 1 unless payload[:name].in? %w(SCHEMA TRANSACTION)
}, "sql.active_record") do
block.call
end
query_count
end
sortよりsort_byを使おう
たとえば、ユーザーの一覧を柔軟に並べ替えたい、という仕様があったとします。
ここでの「柔軟に」とは、複数の並べ替え項目を設定でき、それぞれ昇順と降順を選べる、という意味です。
sortを使った場合
この要件を叶えるため、まず Enumerable#sort
で実装してみます。以下のようなコードです。
sortブロック内で毎回sort_keysメソッドが評価されるので、パフォーマンス的に怪しそうですね。
class UsersSorter
include ActiveModel::Model
include ActiveModel::Attributes
attribute :users
attribute :orders
def sort_by_order_keys
users.sort do |user_a, user_b|
sort_keys(user_a, user_b, 0) <=> sort_keys(user_a, user_b, 1)
end
end
private
def sort_keys(user, other_user, operand)
orders.map do |order|
code = order.code
target_user = (order.asc ? [user, other_user] : [other_user, user])[operand]
infinity = order.asc ? Float::INFINITY : -Float::INFINITY
case code
when "employee_number", "name", "birthday"
default_sort_key(target_user, code, infinity)
when "gender"
default_sort_key(target_user, :gender_before_type_cast, infinity)
when /\Acustom_.+/
template_id = code.delete_prefix("custom_")
user_custom_field = target_user.user_custom_fields.detect { _1.user_custom_field_template_id == template_id }
field_type = template_id_field_type_hash[template_id]
default_sort_key(user_custom_field, :"#{field_type}_value", infinity)
else
raise "Unexpected code: #{code}"
end
end + [[user.id, other_user.id][operand]]
end
def template_id_field_type_hash
@template_id_field_type_hash ||= UserCustomFieldTemplate.pluck(:id, :field_type).to_h
end
def default_sort_key(target_record, attr, infinity)
target_record&.__send__(attr) || infinity
end
end
このコードが実際にどのくらいパフォーマンスなのか、計測してみます。
並べ替え条件は、第1ソートが性別(user.gender)の降順、第2ソートが社員番号(user.employee_number)の降順、第3ソートが数値カスタム項目(user_custom_field.decimal_value)の昇順です。
users = User.preload(user_custom_fields: :user_custom_field_template).load
Order = Struct.new(:code, :asc)
orders = [
Order.new(code: "gender", asc: false),
Order.new(code: "employee_number", asc: false),
Order.new(code: "custom_#{UserCustomFieldTemplate.decimal[0].id}", asc: true),
]
sorter = UsersSorter.new(users:, orders:)
measure_benchmark("sort:": -> { sorter.sort_by_order_keys })
# user system total real
# sort: 49.313671 0.578563 49.892234 ( 50.285167)
結果、約50秒かかりました。Userのレコード数は10万件ですが、それを考慮しても改善は必須です。
sort_byを使った場合
パフォーマンス改善のために、Enumerable#sort_by
で実装を修正してみます。
prepare_sort_keysメソッドを一度だけ評価し、以降はキャッシュした結果を使うのがポイントです。
また、複数のデータ型で昇順と降順を組み合わせて並べ替えできるように、ソートのキーを数値もしくは数値の配列に変換しています。
class UsersSorter
include ActiveModel::Model
include ActiveModel::Attributes
attribute :users
attribute :orders
def sort_by_order_keys
users.sort_by { |user| prepare_sort_keys[user.id] }
end
private
def prepare_sort_keys
@prepare_sort_keys ||= users.each_with_object({}) do |user, hash|
hash[user.id] = orders.map do |order|
code = order.code
infinity = order.asc ? Float::INFINITY : -Float::INFINITY
key = case code
when "employee_number", "name"
string_sort_key(user, code, infinity)
when "birthday"
date_sort_key(user, code, infinity)
when "gender"
default_sort_key(user, :gender_before_type_cast, infinity)
when /\Acustom_.+/
template_id = code.delete_prefix("custom_")
user_custom_field = user.user_custom_fields.detect { _1.user_custom_field_template_id == template_id }
field_type = template_id_field_type_hash[template_id]
sort_method_prefix = { string: :string, text: :string, date: :date, decimal: :default }[field_type.to_sym]
__send__(:"#{sort_method_prefix}_sort_key", user_custom_field, :"#{field_type}_value", infinity)
else
raise "Unexpected code: #{code}"
end
order.asc ? key : invert_sort_key(key)
end + [user.id]
end
end
def invert_sort_key(key)
case key
in [*nums] if nums.all?(Numeric)
nums.map(&:-@)
in Numeric
-key
else
key
end
end
def template_id_field_type_hash
@template_id_field_type_hash ||= UserCustomFieldTemplate.pluck(:id, :field_type).to_h
end
def string_sort_key(target_record, attr, infinity)
target_record&.__send__(attr).to_s.chars.map(&:ord).presence || [infinity]
end
def date_sort_key(target_record, attr, infinity)
target_record&.__send__(attr)&.to_time&.to_i || infinity
end
def default_sort_key(target_record, attr, infinity)
target_record&.__send__(attr) || infinity
end
end
修正版のコードで、実際にどのくらいパフォーマンスが改善するか、計測してみます。
並べ替え条件はsortと同じく、第1ソートが性別(user.gender)の降順、第2ソートが社員番号(user.employee_number)の降順、第3ソートが数値カスタム項目(user_custom_field.decimal_value)の昇順です。
users = User.preload(user_custom_fields: :user_custom_field_template).load
Order = Struct.new(:code, :asc)
orders = [
Order.new(code: "gender", asc: false),
Order.new(code: "employee_number", asc: false),
Order.new(code: "custom_#{UserCustomFieldTemplate.decimal[0].id}", asc: true),
]
sorter = UsersSorter.new(users:, orders:)
measure_benchmark("sort_by:": -> { sorter.sort_by_order_keys })
# user system total real
# sort_by: 2.640865 0.112585 2.753450 ( 2.770077)
結果、3秒弱まで改善できました。sortに比べてsort_byが高速なのがわかりますね。
ancestryと仲良くなろう
つづいて、階層構造を持つデータについてです。
たとえば、指定された部署IDをもとに、その配下の部署IDも含めて取得したいケースを考えましょう。
ちなみに階層構造は、ancestry
というgemを使用して実現しています。
subtree_idsを使った場合
指定された部署ID群およびその配下の部署ID群の取得は、ancestryが提供する subtree_ids
を使って実現できそうです。
class Department < ApplicationRecord
has_ancestry primary_key_format: /[-A-Fa-f0-9]{36}/, ancestry_format: :materialized_path2
class << self
def find_subtree_ids(department_ids)
where(department_id: department_ids).map(&:subtree_ids).flatten
end
end
end
とてもシンプルに実装できましたね。このメソッドを使用して、クエリ発行回数を計測してみます。
target_department_ids = Department.first(100).pluck(:id)
count_queries do
Department.find_subtree_ids(target_department_ids).uniq
end
# => 101
指定した部署ID数が増えるにつれ、クエリ発行回数も増えていきます。もう少し改善できそうですね。
arrangeを使った場合
ancestryが提供する arrange
メソッドで同じことを実現できます。
arrangeで階層構造をHash形式で取得し、それを再帰的に処理することで、配下部署を含めた部署IDを取得しています。
class Department < ApplicationRecord
has_ancestry primary_key_format: /[-A-Fa-f0-9]{36}/, ancestry_format: :materialized_path2
class << self
def find_subtree_ids(department_ids)
# 部署の階層構造をHashで取得
dep_subtrees = arrange
# 指定されたサブツリーから、対象の部署IDおよびその配下の部署IDのみを抽出するlambda
extract_target_dep_ids = lambda do |subtrees, target_ids, is_target = false|
subtrees.each_with_object([]) do |(parent, children), results|
is_descendant = is_target || target_ids.include?(parent.id)
results.push(parent.id) if is_descendant
results.concat(extract_target_dep_ids.call(children, target_ids, is_descendant))
end
end
# lambdaを呼び出す
extract_target_dep_ids.call(dep_subtrees, department_ids)
end
end
end
改善したメソッドのクエリ発行回数を計測してみましょう。
target_department_ids = Department.first(100).pluck(:id)
count_queries do
Department.find_subtree_ids(target_department_ids)
end
# => 1
指定する部署IDがどれだけ増えても、クエリ発行回数は1回に限定できます。
Hashを活用して計算量を削減しよう
配列のままデータを扱うのか、ハッシュに変換してから扱うのか、でもパフォーマンスに影響が出ることがあります。
以下のコードでは、idとnameを持つユーザーデータを100万件生成しています。また、対象となる4種類のIDの配列(10件分、100件、1000件、10000件)を保持しています。
SampleUser = Struct.new(:id, :name)
users = (1..1_000_000).each_with_object([]) do |i, arr|
arr << SampleUser.new(SecureRandom.uuid, "user-#{i}")
end
target_user_ids_10 = users.sample(10).pluck(:id)
target_user_ids_100 = users.sample(100).pluck(:id)
target_user_ids_1000 = users.sample(1000).pluck(:id)
target_user_ids_10000 = users.sample(10000).pluck(:id)
それぞれのID群に紐づくユーザー名を、Enumerable#detect
、Enumerable#filter
、Hash#[]
を使ってそれぞれ取得し、パフォーマンスにどのくらい差が出るか見てみましょう。
detectを使った場合
measure_benchmark(
"detect 対象ID数10件:": -> do
target_user_ids_10.each do |id|
users.detect { _1.id == id }.name
end
end,
"detect 対象ID数100件:": -> do
target_user_ids_100.each do |id|
users.detect { _1.id == id }.name
end
end,
"detect 対象ID数1000件:": -> do
target_user_ids_1000.each do |id|
users.detect { _1.id == id }.name
end
end,
"detect 対象ID数10000件:": -> do
target_user_ids_10000.each do |id|
users.detect { _1.id == id }.name
end
end,
)
# user system total real
# detect 対象ID数10件: 0.591503 0.006023 0.597526 ( 0.625935)
# detect 対象ID数100件: 7.234028 0.038343 7.272371 ( 7.396259)
# detect 対象ID数1000件: 69.891964 0.374523 70.266487 ( 71.383391)
# detect 対象ID数10000件: 683.485529 3.602129 687.087658 (695.061762)
今回のケースではdetectを使うと非効率であることがわかります。
対象のID数が増えるにつれ、急激にパフォーマンスが悪化していますね。
filterを使った場合
measure_benchmark(
"filter 対象ID数10件:": -> do
users.filter { target_user_ids_10.include?(_1.id) }.map(&:name)
end,
"filter 対象ID数100件:": -> do
users.filter { target_user_ids_100.include?(_1.id) }.map(&:name)
end,
"filter 対象ID数1000件:": -> do
users.filter { target_user_ids_1000.include?(_1.id) }.map(&:name)
end,
"filter 対象ID数10000件:": -> do
users.filter { target_user_ids_10000.include?(_1.id) }.map(&:name)
end,
)
# user system total real
# filter 対象ID数10件: 0.229769 0.001474 0.231243 ( 0.231815)
# filter 対象ID数100件: 0.982028 0.005082 0.987110 ( 0.989089)
# filter 対象ID数1000件: 8.534463 0.043995 8.578458 ( 8.606246)
# filter 対象ID数10000件: 97.834058 0.583361 98.417419 ( 99.134713)
detectほどではありませんが、そこまで効率のよい探索方法ではありませんね。
対象のID数に比例して、パフォーマンスは悪化しています。
[]を使った場合
measure_benchmark(
"[] 対象ID数10件:": -> do
users_hash = users.pluck(:id, :name).to_h
target_user_ids_10.each do |id|
users_hash[id]
end
end,
"[] 対象ID数100件:": -> do
users_hash = users.pluck(:id, :name).to_h
target_user_ids_100.each do |id|
users_hash[id]
end
end,
"[] 対象ID数1000件:": -> do
users_hash = users.pluck(:id, :name).to_h
target_user_ids_1000.each do |id|
users_hash[id]
end
end,
"[] 対象ID数10000件:": -> do
users_hash = users.pluck(:id, :name).to_h
target_user_ids_10000.each do |id|
users_hash[id]
end
end,
)
# user system total real
# [] 対象ID数10件: 0.486727 0.009793 0.496520 ( 0.497423)
# [] 対象ID数100件: 0.478129 0.006832 0.484961 ( 0.487415)
# [] 対象ID数1000件: 0.504693 0.006387 0.511080 ( 0.513816)
# [] 対象ID数10000件: 0.487489 0.006453 0.493942 ( 0.494768)
detectやfilterと比較すると効率が良いことがわかります。
対象ID数が増加してもパフォーマンスは悪化しませんね。
おわりに
以上、Ruby / Railsのパフォーマンスに関するTipsを3点ご紹介させていただきました。
基礎的な内容が多かったと思いますが、参考になる点が少しでもあれば幸いです。
皆さんも、よいRuby / Railsライフを!👋
Discussion