Thay vì count associated records trong database mỗi lần page load, tính năng counter caching của ActiveRecord cho phép store counter và update nó mỗi khi associated object được tạo hoặc xóa. Episode AppSignal Academy này học all về caching counters trong ActiveRecord.
Ví dụ classic: blog với articles và responses. Mỗi article có responses, muốn display số responses bên cạnh title mỗi article trên index page để show popularity.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
# ...
end
Không cần preload responses, vì không show data của chúng trên index. Show counter, chỉ quan tâm số responses cho mỗi article. Controller find all articles đặt vào @articles cho view dùng.
<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
<% @articles.each do |article| %>
<article>
<h1><%= article.title %></h1>
<p><%= article.description %></p>
<%= article.responses.size %> responses
</article>
<% end %>
View loop qua mỗi article render title, description, số responses. Vì gọi article.responses.size trong view, ActiveRecord biết cần count association thay vì load whole record cho mỗi response.
Tip: Mặc dù #count nghe intuitive hơn cho counting responses, ví dụ dùng #size, vì #count luôn làm COUNT query, trong khi #size skip query nếu responses đã loaded.
Started GET "/articles" for 127.0.0.1 at 2018-06-14 16:25:36 +0200
Processing by ArticlesController#index as HTML
Rendering articles/index.html.erb within layouts/application
Article Load (0.2ms) SELECT "articles".* FROM "articles"
↳ app/views/articles/index.html.erb:3
(0.2ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 2]]
↳ app/views/articles/index.html.erb:7
(0.3ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 3]]
...
Rendered articles/index.html.erb within layouts/application (23.1ms)
Completed 200 OK in 52ms (Views: 45.7ms | ActiveRecord: 1.6ms)
Request blog index result N+1 queries, ActiveRecord lazy-load response count cho mỗi article separate query.
Dùng COUNT() từ query
Tránh extra query per article, join articles và responses tables để count associated responses trong single query.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.
joins(:responses).
select("articles.*", 'COUNT("responses.id") AS responses_count').
group('articles.id')
end
# ...
end
Ví dụ join responses trong articles query và select COUNT("responses.id") count số responses. Group by product IDs count responses per article. View dùng responses_count thay vì gọi size trên responses association.
Solution này prevent extra queries bằng làm first query slower và complex hơn. Good first step optimize performance, nhưng có thể đi xa hơn cache counter để không count mỗi response every page view.
Counter cache
Vì articles trên blog (hy vọng) read thường hơn updated, counter cache là optimization tốt làm query page nhanh và simple hơn.
Thay vì count số responses mỗi lần display articles, counter cache keep separate response counter stored trong mỗi article database row. Counter update khi response added hoặc removed.
Cho phép article index render với one database query, không cần join responses trong query. Setup: flip switch trong belongs_to relation bằng set option counter_cache.
# app/models/response.rb
class Response
belongs_to :article, counter_cache: true
end
Yêu cầu field trong Article model tên responses_count. Option counter_cache đảm bảo number in field auto update khi response added/removed.
Tip: Field name override bằng dùng symbol thay true value cho counter_cache option.
Tạo column mới database store count.
$ rails generate migration AddResponsesCountToArticles responses_count:integer
invoke active_record
create db/migrate/20180618093257_add_responses_count_to_articles.rb
$ rake db:migrate
== 20180618093257 AddResponsesCountToArticles: migrating ======================
-- add_column(:articles, :responses_count, :integer) -> 0.0016s
== 20180618093257 AddResponsesCountToArticles: migrated (0.0017s) =============
Vì số responses cached trong articles table, không cần join responses trong articles query. Dùng Article.all fetch all articles controller.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
# ...
end
Không cần change view, Rails hiểu dùng counter cache cho #size method.
<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
<% @articles.each do |article| %>
<article>
<h1><%= article.title %></h1>
<p><%= article.description %></p>
<%= article.responses.size %> responses
</article>
<% end %>
Request index lại, thấy one query executed. Vì mỗi article biết số responses, không cần query responses table at all.
Started GET "/articles" for 127.0.0.1 at 2018-06-14 17:15:23 +0200
Processing by ArticlesController#index as HTML
Rendering articles/index.html.erb within layouts/application
Article Load (0.2ms) SELECT "articles".* FROM "articles"
↳ app/views/articles/index.html.erb:3
Rendered articles/index.html.erb within layouts/application (3.5ms)
Completed 200 OK in 42ms (Views: 36.5ms | ActiveRecord: 0.2ms)
Counter caches cho scoped associations
ActiveRecord counter cache callbacks chỉ fire khi create/destroy records, nên add counter cache trên scoped association không work. Advanced cases, như chỉ count *published* responses, check out counter_culture gem.
Populating the counter cache
Articles predating counter cache, counter out of sync, mặc định 0. "Reset" counter cho object dùng .reset_counters method trên nó pass object ID và relation counter nên update.
Article.reset_counters(article.id, :responses)
Đảm bảo run production khi deploy, put trong migration run directly sau add column last migration.
$ rails generate migration PopulateArticleResponsesCount --force
invoke active_record
create db/migrate/20180618093443_populate_article_responses_count.rb
Trong migration, gọi Article.reset_counters cho mỗi article, pass articles IDs và :responses as association name.
# db/migrate/20180618093443_populate_article_responses_count.rb
class PopulateArticleResponsesCount < ActiveRecord::Migration[5.2]
def up
Article.find_each do |article|
Article.reset_counters(article.id, :responses)
end
end
end
Migration này update counts cho all articles trong database including ones existed trước counter cache.
Callbacks
Vì counter caches dùng callbacks update counters, methods directly execute SQL commands (như dùng #delete thay #destroy) không update counters.
Situations đó happen vì lý do gì, có thể add Rake task hoặc background job keep counts in sync periodically.
namespace :counters do
task update: :environment do
Article.find_each do |article|
Article.reset_counters(article.id, :responses)
end
end
end
Cached counters
Prevent N+1 queries bằng count associated objects trong query giúp, nhưng caching counters faster way show counters cho most applications. ActiveRecord built-in cached counters helpful, options như counter_culture cho elaborate requirements.
Có câu hỏi ActiveRecord counter caches? Đừng ngần ngại @AppSignal. Tất nhiên, chúng tôi love biết bạn like bài này như thế nào, hoặc subject khác muốn know more.