冒險村14 - counter cache

14 - counter cache

在許多情況下,會需要統計一對多關聯的資料數量。舉例來說像是 User has_many Post。這時如果要統計該 user 擁有的 post 數量時,若直接使用 User.posts.size 則每次讀取頁面都會重新再統計一次,影響網站效能,而 ActiveRecord’s counter_cache 可以來幫助解決這個問題。


以 User has_many post 的例子來看:

1
2
3
4
5
6
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all
end
end
1
2
3
4
5
6
7
8
9
10
11
# app/views/users/index.html.erb
<% @users.each do |user| %>
<tr>
<td><%= user.name %></td>
<td><%= user.email %></td>
<td><%= user.tel %></td>
<td><%= user.posts.size %></td>
<td><%= link_to 'Show', user %></td>
<td><%= link_to 'Edit', edit_user_path(user) %></td>
</tr>
<% end %>
1
2
3
4
5
6
7
8
9
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
end

# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
end

從 server console 中可以看到 N+1 問題,會重複的一直去撈資料庫。

1
2
3
4
5
6
SELECT "users".* FROM "users"
SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 1]]
SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 2]]
SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 3]]
SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 4]]
SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 5]]

接下來就來新增欄位 field_count(慣例為 xxx_count 結尾) 來透過 counter_cache 來把存數字進資料庫,解決 SQL count 查詢造成的 N+1 問題,神奇的是 Post 數量有更新時,欄位也會自動增減。

產生欄位

1
rails g migration add_posts_count_to_user

migration

1
2
3
4
5
6
7
8
9
class AddPostsCountToUser < ActiveRecord::Migration[6.1]
def change
add_column :users, :posts_count, :integer, default: 0

User.pluck(:id).each do |id|
User.reset_counters(id, :posts)
end
end
end

註: reset_countersActiveRecord::CounterCache method

也可以將重新計算的 counter 的程式寫成 rake 來執行比放在 migration 內來的適合

rake task

1
2
3
4
5
6
7
8
9
# lib/migrate/reset_user_posts_count.rb
namespace :migrate do
desc 'Reset user posts_count counter cache'
task reset_user_posts_count: :environment do
User.pluck(:id).each do |id|
User.reset_counters(id, :posts)
end
end
end

並記得要跑 bundle exec rails migrate:reset_user_posts_count

model

再來在 Post model 修正加上 counter_cache: true 即可。

1
2
3
4
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user, counter_cache: true
end

這時候就會發現原本的 N+1 問題解決了~user.posts.size 這段就會被自動改成 user.posts_count,數量也都會自動更新!

參考來源