この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。
記事投稿の背景
この記事ではXのクローンサイトを作成中にBulletで検知したN+1問題を解決するために実装した内容を紹介します。
機能としては投稿に対するいいねとコメントの数を集計して表示させる機能に関する内容です。
実装当初の内容と修正後の内容を掲載したいと思います。
実装当初のコード
class Tweet < ApplicationRecord
belongs_to :user
validates :content, presence: true, length: { in: 1..140 }
has_many_attached :images
has_many :favorites, dependent: :destroy
has_many :comments, dependent: :destroy
has_many :retweets, dependent: :destroy
end
class Favorite < ApplicationRecord
belongs_to :user
belongs_to :tweet
end
class Comment < ApplicationRecord
belongs_to :user
belongs_to :tweet
belongs_to :parent, class_name: 'Comment', optional: true # 親コメント(返信先) Nillも許容
has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :comment # 返信
validates :sentence, presence: true, length: { in: 1..140 }
has_many_attached :images
end
.icons
.comment
= link_to image_tag('comment.png', size: '26x26', class: "balloon"), tweet_path(tweet.id)
= tweet.comments.count # 今回の記事で肝となる部分
.repost
= image_tag('repost.png', size: '26x26')
.favorite
- if current_user.present? && tweet.favorites.exists?(user: current_user)
= link_to image_tag('heart.png', size: '32x32'), tweet_favorite_path(tweet_id: tweet.id, id: tweet.favorites.find_by(user: current_user).id), data: {turbo_method: :delete}
= tweet.favorites.count # 今回の記事で肝となる部分
- else
= link_to image_tag('heart_with_hole.png', size: '32x32'), tweet_favorites_path(tweet_id: tweet.id), data: {turbo_method: :post}
= tweet.favorites.count # 今回の記事で肝となる部分
遭遇した検知文と実装当初の状況
user: root
GET /home/index?recommend=9&tab=recommend
Need Counter Cache with Active Record size
Tweet => [:comments]
user: root
GET /home/index?recommend=9&tab=recommend
Need Counter Cache with Active Record size
Tweet => [:favorites]
Bulletからcounter_cacheの使用を促されました。
tweet, comment, favoriteの3モデル間についてアソシエーションは、tweet has many comments and favorites
となります。
直訳でtweetモデルが沢山の(複数の)commentsとfavoritesを持っている
実装当初では1つのtweetに関わるcommentやfavoriteを取得する場合、
tweet.comments.count
や tweet.favorites.count
と実装しています。
この場合、毎回commentsテーブルやfavoritesテーブルにクエリが走ってパフォーマンスは良くないです。
その結果、Bulletから指摘されています。
counter_cacheの導入と変更後のコード
-
counter_cacheとは?
関連するモデルの関連数(ここではcommentとfavorite)を効率的に管理するための機能です。
関連するデータの数をキャッシュとして保持することで、N+1クエリ発生を防ぎパフォーマンスを向上させます。 -
counter_cacheの導入
関連する親モデル(ここではtweetモデル)のテーブルに数をキャッシュするカラムを追加します。
class Comment < ApplicationRecord
belongs_to :user
belongs_to :tweet, counter_cache: :count_of_comments # このカラムに集計した数を保管
belongs_to :parent, class_name: 'Comment', optional: true # 親コメント(返信先) Nillも許容
has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :comment # 返信
validates :sentence, presence: true, length: { in: 1..140 }
has_many_attached :images
end
count_of_comments
というカラムをtweetテーブルに作成して、tweetに関連するcommentsの数をこのカラムに保管します。
class Favorite < ApplicationRecord
belongs_to :user
belongs_to :tweet, counter_cache: :count_of_favorites # このカラムに集計した数を保管
end
count_of_favorites
というカラムをtweetテーブルに作成して、tweetに関連するfavoritesの数をこのカラムに保管します。
- データベースにカウンタ用のカラムを実際に追加していきます。
def change
add_column :tweets, :count_of_comments, :integer, default: 0, null: false
add_column :tweets, :count_of_favorites, :integer, default: 0, null: false
end
end
これで各tweetに関するcommentとfavoriteの数を自動で集計してくれるようになりました。
新しくfavoriteやcommentが作成されるとRailsの中で自動的にcount_of_favoritesとcount_of_commentsの数を増やします。逆に削除されれば数を減らします。
これによりtweet.comments.count
やtweet.favorites.count
ではなく、
tweet.count_of_comments
やtweet.count_of_favorites
でデータベースにクエリを発行せずにfavoriteやcommentの数を取得できるようになりました。
- カウントのリセット
既存のデータに対してcount_of_comments
とcount_of_favorites
の数をリセットするため、reset_counters
メソッドを使います。
def up
Tweet.find_each do |tweet|
Tweet.reset_counters(tweet.id, :comments) # commentsのカウントをリセット
Tweet.reset_counters(tweet.id, :favorites) # favoritesのカウントをリセット
end
end
def down
# 元に戻す処理は不要
end
これでそれぞれのcounterが全てのtweetに対して正しくリセットされます。
以上までの実装したコードでN+1問題を回避し、Bulletからの指摘も無くなりました。
最後まで読んで頂き有難うございます。
今回参考にさせて頂いた記事
Railsガイド
api.rubyonrails.org
DBパフォーマンスを意識したRailsの書き方まとめ
余計なクエリ数を減らしたい。counter_cacheの検討からcounter_cultureの導入まで。