自分が使ったことのあるテクニックのいくつかを小ネタとしてメモしておきます。
免責事項
- パフォーマンスチューニングはサービス全体から判断することなので、以下の例は高速化の可能性を示すだけです。いくつか取れる選択肢のうちで、その状況ではこう対応したというだけなので、状況によっては正反対の対応をするものもあり、この場合にはこうすればよいと推奨するものではありません。
- 以下のサンプルコードは説明のためのでっちあげであり、例としておかしい可能性はあります。
- 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_to
や includes
を使わずに実装できてリリースは簡単ですが、キャッシュに割り当てるメモリももったいなく、不要になった場合にオンラインでテーブルからカラムを削除するのは難しくなります。適切な名前のモデルを別に定義しましょう。
has_many
と 併用した has_one
を includes
に含めない
本来 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_many
と has_one
を併用する場合があります。このとき、 includes
に has_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)
に書き換えます。
-
ID
とCREATED_AT
の順序の差違にこだわらなければならないケースが稀であり、CREATED_AT
カラムに対するインデックスが削減できます。 -
select
メソッドでSELECT
句の対象カラムを絞ることで、カバリングインデックスが使えるケースがあります。 一方でid
カラムは省略できないケースが多いです。 -
created_at
とした場合に、当時ミリ秒以下の時刻永続化に対応しないアダプターがあり、順序を検証するテストが特定の実行環境で落ちることがありました。 created_at ではなく id の比較にすればsleep 1
などを挟まずに済みます。
バリデーションが呼ばれた際に belongs_to
が発行する SQL
Rails 5 から belongs_to
は required: 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_items
と items
が多段でロードされます。 item.id
の配列だけ必要なのであれば、次のようにできます。
items = User.target.preload(:favorite_items)
FooSearchEngine.search(item_ids: favorite_items.map(&:item_id))
さらに、 select
を使った ActiveRecord::Relation
を merge
することでカバリングインデックスを有効にします。
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!
のように処理をすると SELECT
と DELETE
で 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割ぐらいは書いたので、もっとえげつないのもあったりしますが、説明が難しいのでこのへんで終了します。