Railsアプリの処理を100倍以上に高速化して得られた知見
濱田 裕太
はじめまして。2019年4月から妊娠・出産アプリ『Babyプラス』の開発チームにJOINした濱田です。
『Babyプラス』のバックエンドはRailsで実装されているのですが、とあるCSV生成処理がとても遅かったので100倍以上に高速化しました。この過程でRailsアプリの処理高速化に関する以下の知見が得られたので、具体例を交えて共有します。この知見は、ActiveRecordを使用してMySQLなどのRDBMSからデータ抽出をする様々な場面で活用できると思います。
- いわゆる「N+1問題」を起こさないのは基本
- 「ActiveRecordインスタンスの生成コスト」はそれなりに高い
pluck
はjoins
と組み合わせることで他テーブルのカラム値も取得できる
前提: DBスキーマとデータ規模
今回の処理高速化に関わるモデルのDBスキーマとデータ規模は以下の通りです。なお、これらは本エントリ向けに少しアレンジされており、実際のものとは若干異なります。
DBスキーマ
CREATE TABLE `prefectures` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `cities` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`prefecture_id` bigint(20) DEFAULT NULL,
`name` varchar(255) NOT NULL,
`city_code` int(11) DEFAULT NULL,
`ordinance_city_code` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `index_cities_on_prefecture_id` (`prefecture_id`),
KEY `index_cities_on_city_code_and_ordinance_city_code` (`city_code`,`ordinance_city_code`),
CONSTRAINT `fk_rails_cc74ecd368` FOREIGN KEY (`prefecture_id`) REFERENCES `prefectures` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `hospitals` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`city_id` bigint(20) DEFAULT NULL,
`name` varchar(255) NOT NULL,
`name_kana` varchar(255) DEFAULT NULL,
`address` varchar(255) DEFAULT NULL,
`postal_code` varchar(255) DEFAULT NULL,
`hospital_code` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `index_hospitals_on_city_id` (`city_id`),
CONSTRAINT `fk_rails_52308f6f48` FOREIGN KEY (`city_id`) REFERENCES `cities` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
データ規模
テーブル名 | レコード数 |
---|---|
prefectures | 47 |
cities | 1000 |
hospitals | 3000 |
(1) 従来版: 単純なall.each
(8.669 sec)
(1-1) 実装と結果
CSV生成部分のコード
CSV.generate do |row|
row << %w[
id
name
name_kana
postal_code
prefecture_name
city_id
city_name
address
hospital_code
]
Hospital.all.each do |hospital|
row << [
hospital.id,
hospital.name,
hospital.name_kana,
hospital.postal_code,
hospital.city.prefecture.name,
hospital.city.id,
hospital.city.name,
hospital.address,
hospital.hospital_code,
]
end
end
発行されたクエリ
SELECT `hospitals`.* FROM `hospitals`;
-- 1回目
SELECT `cities`.* FROM `cities` WHERE `cities`.`id` = 1 LIMIT 1;
SELECT `prefectures`.* FROM `prefectures` WHERE `prefectures`.`id` = 1 LIMIT 1;
-- 2回目
SELECT `cities`.* FROM `cities` WHERE `cities`.`id` = 2 LIMIT 1;
SELECT `prefectures`.* FROM `prefectures` WHERE `prefectures`.`id` = 1 LIMIT 1;
.
.
.
-- 3000回目
SELECT `cities`.* FROM `cities` WHERE `cities`.`id` = 1000 LIMIT 1;
SELECT `prefectures`.* FROM `prefectures` WHERE `prefectures`.`id` = 47 LIMIT 1;
実行結果
Completed 200 OK in 8669ms (Views: 8239.2ms | ActiveRecord: 427.6ms)
(1-2) 結果の考察
実行結果を見ると、たかだか3000レコードのCSV生成に8秒以上もかかっています。
発行されたクエリを見ると、hospitals
の関連テーブルであるcities
とprefectures
の取得クエリがそれぞれ3000回ずつ発生していました。これはHospital.all.each
のループ内でhospital.city
, hospital.city.prefecture
がそれぞれ最初に評価される際に取得クエリが都度発行されるためです。いわゆる「N+1問題」と呼ばれる現象です。
(2) 改善版: includes
の利用 (1.476 sec)
(2-1) 実装と結果
CSV生成部分のコード
CSV.generate do |row|
row << %w[
id
name
name_kana
postal_code
prefecture_name
city_id
city_name
address
hospital_code
]
Hospital.includes(city: :prefecture).all.each do |hospital|
row << [
hospital.id,
hospital.name,
hospital.name_kana,
hospital.postal_code,
hospital.city.prefecture.name,
hospital.city.id,
hospital.city.name,
hospital.address,
hospital.hospital_code,
]
end
end
発行されたクエリ
SELECT `hospitals`.* FROM `hospitals`;
SELECT `cities`.* FROM `cities` WHERE `cities`.`id` IN (1, 2, ..., 1000);
SELECT `prefectures`.* FROM `prefectures` WHERE `prefectures`.`id` IN (1, 2, ..., 47);
実行結果
Completed 200 OK in 1231ms (Views: 1208.8ms | ActiveRecord: 20.3ms)
(2-2) 結果の考察
発行されたクエリを見ると、hospitals
, cities
, prefectures
の取得クエリがそれぞれ1回ずつになり、DB処理の実行時間が427.6ms → 20.3msに改善されました。これはincludes
という Eager loading を実現するためのメソッドにより「N+1問題」が解消されたからです。
しかしViewsの実行時間は1208.8msもあり、処理全体としてそこまで速くなったとは言えません。そこでこの処理をプロファイリングした結果、「ActiveRecordインスタンスの生成コスト」がボトルネックであることが分かりました。hospitals
が3000レコード、cities
が1000レコード、prefectures
が47レコードあり、その数だけActiveRecordインスタンスが生成されます。さらにこのとき、それぞれのレコード数×カラム数だけActiveRecord::AttributeMethods
関連オブジェクトのメソッドが呼ばれるため、生成コストが高くなります。また、hospitals
の関連レコードであるcities
およびcities
の関連レコードであるprefectures
とのオブジェクト関連付け処理も行われるため、いっそう生成コストが高くなります。
発行されたクエリを見れば分かるように、hospitals
, cities
, prefectures
のそれぞれで全てのカラムを取得しているため、CSV生成に使わないカラムの処理コストは無駄になってしまいます。しかしincludes
を使っているため、select メソッドで取得カラムを限定するアプローチは上手く動作しません。かといってincludes
相当の処理をselect
付きで独自実装すると、コードが煩雑になりメンテナンス性が著しく低下してしまいます。
(3) 最終版: joins
とpluck
の合わせ技 (0.079 sec)
(3-1) 実装と結果
CSV生成部分のコード
CSV.generate do |row|
mapping = {
id: 'hospitals.id',
name: 'hospitals.name',
name_kana: 'hospitals.name_kana',
postal_code: 'hospitals.postal_code',
prefecture_name: 'prefectures.name',
city_id: 'cities.id',
city_name: 'cities.name',
address: 'hospitals.address',
hospital_code: 'hospitals.hospital_code',
}
csv_columns = mapping.keys
db_columns = mapping.values
row << csv_columns
Hospital.order(:id).joins(city: :prefecture).pluck(*db_columns).each do |values|
row << values
end
end
発行されたクエリ
-- 可読性のために改行&インデントを入れている
SELECT hospitals.id, hospitals.name, hospitals.name_kana, hospitals.postal_code,
prefectures.name, cities.id, cities.name, hospitals.address, hospitals.hospital_code
FROM `hospitals`
INNER JOIN `cities` ON `cities`.`id` = `hospitals`.`city_id`
INNER JOIN `prefectures` ON `prefectures`.`id` = `cities`.`prefecture_id`
ORDER BY `hospitals`.`id` ASC;
実行結果
Completed 200 OK in 79ms (Views: 53.0ms | ActiveRecord: 24.2ms)
(3-2) 結果の考察
発行されたクエリを見ると、INNER JOIN
を含む1つだけのクエリになっています。また、Viewsの実行時間が1208.8ms → 53.0msに改善され、全体処理時間も79msとなり、従来版の8669msと比較すると100倍以上の高速化が実現できました。これはjoins
により「N+1問題」が解消され、pluck
により「ActiveRecordインスタンスの生成コスト」が解消されたからです。さらに、pluck
に渡すカラム名をCSV生成に必要なものに限定しているので、カラム値取得の処理コストも最小化できています。
pluck は、ActiveRecordインスタンスの配列ではなく指定されたカラムの取得値配列を返すメソッドです。ActiveRecordインスタンスの生成を伴わないぶん、高速に結果を取得することが可能になります。さらに、pluck
は joins メソッドと組み合わせることで他テーブルのカラム値も取得することが可能です。この際、pluck
に"#{column_name}"
または"#{table_name}.#{column_name}"
以外の文字列や*
で引数展開されていない配列を渡すとRails5.2ではDangerous query method
のDEPRECATION WARNINGが発生するので注意が必要です。(Rails6.0からはエラーになります)
参考: https://github.com/rails/rails/pull/27947
ちなみに order メソッドを使ってorder(:id)
を明記しているのは、そうしないと取得結果の順序(すなわちソートキー)がhospitals.id
の昇順とはならない場合があるからです。MySQLでは、テーブルに格納されているデータの状況に応じてクエリの実行計画が変わり、それによって暗黙のソートキーも変わり得ます。特にJOIN
を伴うクエリでは、テーブル結合に用いるキー(今回のケースではhospitals.city_id
)が暗黙のソートキーになりやすいです。
まとめ
CSV生成処理の性能改善を通じて、Railsアプリの処理高速化に関する以下の知見が得られました。この知見は、ActiveRecordを使用してMySQLなどのRDBMSからデータ抽出をする様々な場面で活用できると思います。
- いわゆる「N+1問題」を起こさないのは基本
- 「ActiveRecordインスタンスの生成コスト」はそれなりに高い
pluck
はjoins
と組み合わせることで他テーブルのカラム値も取得できる
高速化は手当たり次第やれば良いというものではないし、使いどころを誤るとコードのメンテナンス性低下などで「開発速度」が下がってしまうこともあります。しかしこういった知見を引き出しに入れておくことで、効果的な高速化を実現できる可能性が高くなるのではないかと思います。本エントリがその一助となれば幸いです。