Railsアプリの処理を100倍以上に高速化して得られた知見

はじめまして。2019年4月から妊娠・出産アプリ『Babyプラス』の開発チームにJOINした濱田です。

『Babyプラス』のバックエンドはRailsで実装されているのですが、とあるCSV生成処理がとても遅かったので100倍以上に高速化しました。この過程でRailsアプリの処理高速化に関する以下の知見が得られたので、具体例を交えて共有します。この知見は、ActiveRecordを使用してMySQLなどのRDBMSからデータ抽出をする様々な場面で活用できると思います。

  • いわゆる「N+1問題」を起こさないのは基本
  • 「ActiveRecordインスタンスの生成コスト」はそれなりに高い
  • pluckjoinsと組み合わせることで他テーブルのカラム値も取得できる

前提: 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の関連テーブルであるcitiesprefecturesの取得クエリがそれぞれ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) 最終版: joinspluckの合わせ技 (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インスタンスの生成を伴わないぶん、高速に結果を取得することが可能になります。さらに、pluckjoins メソッドと組み合わせることで他テーブルのカラム値も取得することが可能です。この際、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インスタンスの生成コスト」はそれなりに高い
  • pluckjoinsと組み合わせることで他テーブルのカラム値も取得できる

高速化は手当たり次第やれば良いというものではないし、使いどころを誤るとコードのメンテナンス性低下などで「開発速度」が下がってしまうこともあります。しかしこういった知見を引き出しに入れておくことで、効果的な高速化を実現できる可能性が高くなるのではないかと思います。本エントリがその一助となれば幸いです。