Railsでゴリ押しN+1改善
2020-11-29
N+1 の改善
関連テーブルを取得するコードで N+1 が発生していたのでゴリ押しで改善した話。関連テーブルといいつつも、モデルは関連付けがされていなかった。また、DB 側(mysql)でも外部キーを使った関連付けはされていません。そして、既存のモデルに後付けで関連付けをしたことによる影響が現時点ではわからかったので別の方法を検討。(ちなみに、レコード件数は親子それぞれ数万件とそこまで多くないです。それでもかなり遅かった。)方法としては、
- N+1 を回避して SQL の発行回数を減らす
- 発行する SQL の速度改善
という点を意識して検討。 ということで
- 親と子でそれぞれ必要なデータを取得し、コードで紐づける
- mysql のインデックスを活用し SQL の速度を改善
という方法を取りました。
計測方法としては、benchmark を view に設置して view の表示速度で確認。
実装方法
親と子でそれぞれ必要なデータを取得してコードを紐付ける
こちらの具体的なやり方としては、
- 親と子のデータの取得をするために計 2 回 SQL を発行
- コードで関連を確認し一致すれば表示する
というような形で実装してみました。
まず親と子のデータを取得
# 親の取得
parents = Parent.where(stock: 1)
# 子の取得
# 子供を検索するための親のキー群を取得
parent_ids = parents.map(&:id)
# 取得
childs = Child.where(parent_id: parent_ids)
そしてコードで親ごとに所属する子のデータを表示
# 子がいるかどうかを判別するために子のキー群を取得
child_ids = childs.map(&:parent_id)
parents.each do |parent|
puts "parent_id: #{parent.id} parent_nm: #{parent.nm}"
if child_ids.include?(parent.id)
childs.each do |child|
puts "child_id: #{child.id} child_nm: #{child.nm}" if parent.id == child.parent_id
end
end
end
インデックスを作成
例として表示しているコードは単純化していますが、実際はカラムが多くて、なおかつ where や order が複数指定されている SQL になっています。この検索条件に対して最適なインデックスが貼られていなかったので、SQL の速度も遅い状況でした。そのため、親と子でそれぞれお該当する検索条件に沿った複合インデックスを作成することで、300 ~ 700ms 程度だった activerecord の実行速度を、5ms ~ 30ms 程度に改善。
// こんな感じの複合インデックスを作成
ALTER TABLE parents ADD INDEX index_hoge_huga(hoge, huga)
ALTER TABLE parents ADD INDEX index_childhoge_childhuga(childhoge, childhuga)
結果
URL1
2.252635375 → 0.456250689
URL2
7.757510058 → 0.422813077
URL3
1.047131442 → 0.424010427
URL4
1.294398296 → 0.424908621
まとめ
関連の少ない or ないデータに関してはインデックスのみの速度改善で、それほど大きな改善はできなかったです。ただし、子が多いデータのページに関して、全体的に約半分以下の速度に収めることができた。そもそも関連付けをすればいい話ではあるけど、いったん影響がわからないのでこちらで対応。また、関連しているデータを表示する際に毎回ループを回しているのは明らかに無駄で、もっといい方法があるはず。とりあえず N+1 がひどくて速度が遅い点の改善が先だったので、いったん速度改善はできたためよしとしていますが、アルゴリズム勉強して再実装が必要なのは反省。
参考
ActiveRecord の joins と preload と includes と eager_load の違い
【Ruby on Rails】N+1 問題ってなんだ?
N+1 問題は 1+N 問題