This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

More than 5 years have passed since last update.

ActiveRecord 高速化アレコレ

Last updated at Posted at 2019-03-26

自分が使ったことのあるテクニックのいくつかを小ネタとしてメモしておきます。

免責事項

  • パフォーマンスチューニングはサービス全体から判断することなので、以下の例は高速化の可能性を示すだけです。いくつか取れる選択肢のうちで、その状況ではこう対応したというだけなので、状況によっては正反対の対応をするものもあり、この場合にはこうすればよいと推奨するものではありません。
  • 以下のサンプルコードは説明のためのでっちあげであり、例としておかしい可能性はあります。
  • bullet gem 使いましょう、 development.log を見ましょう、モニタリングしましょう、実行計画読みましょうといった初歩的なことは挙げません。
  • RDBMSのエンジンによって、事情が異なる箇所があります。また、RDBMSのエンジンのパラメータチューニングといったことには立ち入りません。
  • ActiveRecord には日々改善はバグフィックスがされているので、最新の動作と異なる可能性があります。

基本的な考え方

  • AS IS と TO BE を考え、様々な事項のバランスを取っていきます。
    • リード回数 <=> ライト回数
    • 導出属性(カラム) <=> キャッシュ属性(カラム)
    • 実行時集計 <=> サマリテーブル
    • 実行時間が最小 <=> 実行コードパスが最小
    • 明示的な表現 <=> 暗黙的な表現
    • インデックスは、 SELECT 句に指定できないが、ソート処理を事前に計算したものや、導出に必要な処理を途中まで済ませたものと考えることができます。
  • SQL の本数、読み取るカラム、結合するテーブルは少ない方がよい
  • ActiveRecordのインスタンス生成は少ない方がよい
  • 基本は Lazy Load
    • ActiveRecord::Relation をビューに返す前にコントローラ内で SQL をトリガーする方針を取るプロジェクトもあるらしいが、同じアクションでフォーマットごとに柔軟に対応するため
  • カバリングインデックスを最大限有効に使う
  • etc.

具体的なアレコレ

インデックスの効かないカラムに対する JOIN 句や WHERE 句

バッチ処理で極端に時間のかかっている SQL がありました。調べると JOIN 句と WHERE 句で指定されているカラムがインデックスに指定されておらず、ネステッドループ結合でインデックススキャンが使えず、テーブルフルスキャンをしていました。

class Item < ApplicationRecord
  scope :high_price, -> { where('price > ?', 10_000) }
end
# PRICE カラムがインデックスに含まれていないのでフルテーブルスキャンしてしまう
Item.high_price.find_each do |item|
  # do something...
end

単純にインデックスを追加してしまえば済むところですが、次のような理由から、インデックスを追加したくありませんでした。

  • 対象となる 2、3 のテーブルは大きい件数を扱う
  • 頻繁に実行する処理ではないため、

そこで、 JON 句や WHERE 句を ActiveRecord 継承クラスのインスタンスメソッドに書き換えます。

class Item < ApplicationRecord
-  scope :high_price, -> { where('price > ?', 10_000) }
+  def high_price?
+    prive > 10000
+  end
end
-Item.high_price.find_each do |item|
+Item.find_in_batches do |items|
+  items.select(&:high_price?).each do
    # do something...
+  end
end

この書き換えの際に、あらかじめ、 reject で落ちる割合がそう多くないことを確認しています。また、1000件毎にログ出力する要件があったりすれば、 Array などにいったん貯めてから出力する必要があります。

できるかぎり null: false オプションを使う

予想外に表示時間のかかる画面があったので、確認したところ、インデックスが使われるべき SQL でインデックスが効いおらずテーブルフルスキャンになっていました。よくよく db/schema.rb を確認すると、インデックスに含まれているカラム(仮に foo_flag とする)に NULL が入り得ないのに null: false が指定されていませんでした。

実レコードの統計情報以外に、 DDL も実行計画に加味されているようで、以下のマイグレーションをリリースするだけでデータの変更もなく、レスポンスは3倍高速化して許容範囲に収まりました。

change_column_null :items, :foo_flag, false

NULLABLE なカラムができることが、 DB 設計として問題があるのではと考えるべきで、 null: false を基本とすべきです。 ActiveRecord の has_one オプションの注意点 - Togetter に書いたような問題を避けることもできます。

ついでに言及しておくと、 add_column のマイグレーションをリリースする際に null: false を指定しようとすると、 default の値が必要になるので、安易に追加してしまうのをたびたび見かけます。次のような理由で、これはよくないやり方だと考えます。

  • 未指定ならばバグとして扱って早期にエラーにする方がよい
    • 規模が大きいほど共通化したり省略したくなるが、明示する
  • ActiveRecord のインスタンス生成時の挙動が違う
    • after_initialize でも同様のことはできる(それはそれで別の問題があるが)
  • ActiveRecord を使わずに SQL を実行した場合も未指定で動作してしまう

代わりに、ひと手間かけて、次の順でマイグレーションを書きます。

  • add_column
  • update_all / update
  • change_column_null

余談になりますが、 Oracle では、 DEFAULT 句を一度付けると厳密には戻せないという制約があります。
https://www.shift-the-oracle.com/sql/alter-table-column.html

デフォルト値を削除する構文はない。
デフォルト値を NULL にすることで同じ振る舞いにすることはできる。

none を返す named scope

次のような scope があるとします。

scope :foo, ->(ids) { where(id: ids) }

ここで、もし、 ids に空配列が来る可能性がある場合、次のようにして SQL をスキップできます。

scope :foo, ->(ids) {
  return none if ids.empty?

  where(id: ids)
}

none は SQL が実行されません。 WHERE 句に 1=0 という条件が追加され、レコードが取得できないですが SQL が発行されます。

devise gem のテーブルに対して add_column しない

ユーザのライフサイクル(LTV のライフタイムと同様の意味)で 1 度しか使わないカラムをアクセスの度に読み取っているケースがありました。 devise gem で生成したテーブルは、様々な属性を記録するようになっていますが、プラグイン等以外ではカラムの追加を避けましょう。 add_column を使うと has_one / belongs_toincludes を使わずに実装できてリリースは簡単ですが、キャッシュに割り当てるメモリももったいなく、不要になった場合にオンラインでテーブルからカラムを削除するのは難しくなります。適切な名前のモデルを別に定義しましょう。

has_many と 併用した has_oneincludes に含めない

本来 has_many

class User < ApplicationRecord
  has_many :items, class_name: 'Item'
  has_one :first_item, -> { order(:id) }, class_name: 'Item', foreign_key: 'item_id'
end

のように、同一のモデルに対して、 has_manyhas_one を併用する場合があります。このとき、 includeshas_one で定義されている方の関連を記述すると、 1対1ではなく、1対多で DBサーバーからペイロードが返ってきます。 includes から除外して N + 1 クエリを受け入れた方が速い場合があります。

t.boolean のフラグカラム

フラグとビジネストランザクションの日付を合わせてカラムとして持つテーブルが見られることがあります。

t.boolean :deleted
t.datetime :deleted_at

以下のような理由でこのような状況になるのではないかと推測します。

  • 最初は、 t.boolean :deleted のみだったが、後で日時も必要になり、追加した。
  • SQL の WHERE 句に deleted を指定しているが、時刻も必要だった。

対応するモデルと次のような処理があるとします。

class User < ApplicationRecord
  scope :deleted, -> { where(deleted: true) }
User.deleted.order(deleted_at: :desc)

これで生成される SQL を高速にしようとすれば、

add_index :users, [:deleted, :deleted_at]

といったインデックスになります。 Ruby 上では、 deleted?deleted_at のみあれば、等価なメソッドで容易に導出できます。 deleted カラムは永続化不要で、リッチな方のデータのみ保存しておけば済みます。

class User < ApplicationRecord
- scope :deleted, -> { where(deleted: true) }
+ scope :deleted, -> { where.not(deleted_at: nil) }

+ def deleted?
+   deleted_at.present? # present? を付けるかは好みにまかせる
+ end

テーブルもインデックスのカラム数が少なく済みます。

add_index :users, :deleted_at

IS NULL 検索が 遅い といった反論があるかもしれませんが、最近のRDBMSでは過去のものとなりつつあります。

CREATED_AT に対するインデックス

order(created_at: :desc)order(id: :desc) に書き換えます。

  • IDCREATED_AT の順序の差違にこだわらなければならないケースが稀であり、CREATED_AT カラムに対するインデックスが削減できます。
  • select メソッドで SELECT 句の対象カラムを絞ることで、カバリングインデックスが使えるケースがあります。 一方で id カラムは省略できないケースが多いです。
  • created_at とした場合に、当時ミリ秒以下の時刻永続化に対応しないアダプターがあり、順序を検証するテストが特定の実行環境で落ちることがありました。 created_at ではなく id の比較にすれば sleep 1 などを挟まずに済みます。

バリデーションが呼ばれた際に belongs_to が発行する SQL

Rails 5 から belongs_torequired: true がデフォルトになっています。これにより、バリデーションが呼ばれた際に、関連オブジェクトのインスタンスをロードする SELECT 文が実行されます。(バージョンアップの際に SELECT 文が大量に増えたことを指摘する人があまりいなかったような気がする。)find_or_initialize_by 等のメソッドの引数に id ではなく ActiveRecord インスタンスを渡すとこの SQL をスキップできます。

has_many through の中間テーブルが発行する SQL

これも先程の belongs_to 同様に、中間テーブルの belongs_to によって、関連モデルのインスタンスをロードする SQL が実行されますが、

favorite_item = user.favorite_items.where(item: item).find_or_initialize
item.save!

次のようなトリッキーなコードを挟むと SQL を抑止できます。

favorite_item = user.favorite_items.where(item: item).find_or_initialize
favorite_item.item = item # 意味不明なので何かコメントを追加しておく
favorite_item.save!

この方法は、デメリットとして bullet gem の誤検知に合います。

has_many through のモデルへのインデックス

class User < ApplicationRecord
  has_many :favorite_items
  has_many :items, through: :favorite_items
end

とした場合、

user = User.find(id)
user.items

と実行するのと

User.includes(:items).find(id)

と実行するのとでは、 SQL の種類が異なります。上記のように単独のインスタンスからメソッド呼び出しでロードしたのでは、中間テーブルのカラムが SELECT 句に含まれない INNER JOIN を使った SQL が実行されます。
よって、カバリングインデックスとなる SQL が設計できます。また、複合一意成約がある場合は、ユニークキーをカバリングインデックスにできます。

add_index :favorite_items, [:user_id, :item_id], unique: true

ただし、 ID 列を含めないとフォームなどで使えないので、 ID が必要になるなら、 カバリングインデックスの末尾に ID 列を追加する

add_index :favorite_items, [:user_id, :item_id, :id]

このような 3 カラムのインデックス場合に実行計画では 2 カラムのユニークインデックスの方が優先されてしまい、カバリングインデックスとならないことがあります。その場合は、ユニークインデックスの列を逆転させます。

# これが使いたいカバリングインデックス
add_index :favorite_items, [:user_id, :item_id, :id]
# キー順を逆転させる
add_index :favorite_items, [:item_id, :user_id], unique: true
remove_index :favorite_items, [:user_id, :item_id], unique: true

また、該当する SQL がなければ、 単独カラムのインデックスは削除してもよいでしょう。デメリットとして、 rails_best_practices gem の警告に合うはずです。

through の先のモデルを使わずに中間テーブルで代用する

検索エンジンにパラメータを渡す場合など、 id のみあればよいケースがあります。

items = User.target.preload(favorite_items: :items) # preload(:items) とも書ける
FooSearchEngine.search(item_ids: items.map(&:id))

とすると、 favorite_itemsitems が多段でロードされます。 item.id の配列だけ必要なのであれば、次のようにできます。

items = User.target.preload(:favorite_items)
FooSearchEngine.search(item_ids: favorite_items.map(&:item_id))

さらに、 select を使った ActiveRecord::Relationmerge することでカバリングインデックスを有効にします。

favorite_items = User.target.preload(:favorite_items).merge(FavoriteItem.select(:item_id))
FooSearchEngine.search(item_ids: favorite_items.map(&:item_id))

validates uniqueness

バリデーション実行時に、一意かどうかをチェックする SQL が実行されますが、念の為程度しか効果がないケースも多く INSERT / UPDATE SQL 発行直前の状態でしかないため、実際に SQL が発行されたタイミングでユニークインデックスによる一意成約によりエラーになることがありえます。コントローラで ActiveRecord::RecordNotUnique を rescue するとして、モデルでは、バリデーションのコンテキストを限定して SQL を減らすことができます。

後から user_id が更新されることがないなら、

validates :user_id, uniqueness: true, on: :create

INSERT 時のチェックを DB に任せるなら、

validates :user_id, uniqueness: true, on: :update

といった風にコンテキストを限定すことができます。

また、

class FavoriteItem < ApplicationRecord
  belongs_to :user
  belongs_to :item

  validates :user_id, uniqueness: { scope: :item_id }
  validates :item_id, uniqueness: { scope: :user_id }
end

という書き方は SQL が無駄に発行されるので、

class FavoriteItem < ApplicationRecord
  belongs_to :user
  belongs_to :item

  validates :user_id, uniqueness: { scope: :item_id }
- validates :item_id, uniqueness: { scope: :user_id }
end

とします。

destroy を使わずに delete_all

favorite_item = current_user.favorite_items.find_by!(item_id: item_id)
favorite_item.destroy!

のように処理をすると SELECTDELETE で 2 回 SQL が発行されるため、

current_user.favorite_items.where(item_id: item_id).delete_all

のように処理できます。
厳密にやろうとすると delete_all に戻り値が 1 かどうかチェックする必要があるため、自分は、よほどのことがなければ ActiveRecord のインスタンスで処理をします。 UPDATE SQL でも同様のことができますが、

  • バリデーションをスキップする
  • update_all の引数に updated_at: Time.zone.now を入れ忘れると updated_at の値が信用ならないものになる

という注意が必要です。

とまぁ、自分がやったことの2割ぐらいは書いたので、もっとえげつないのもあったりしますが、説明が難しいのでこのへんで終了します。

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up