แทนที่จะ count associated records ใน database ทุกครั้งที่ page loads ActiveRecord's counter caching feature อนุญาตให้ store counter และ update มันทุกครั้งที่ associated object ถูกสร้างหรือลบ ใน episode ของ AppSignal Academy นี้ เราจะเรียนรู้ทั้งหมดเกี่ยวกับ caching counters ใน ActiveRecord
มาใช้ classic example ของ blog ที่มี articles และ responses แต่ละ article สามารถมี responses และเราอยาก display จำนวน responses ถัดจาก title ของแต่ละ article บน blog's index page เพื่อแสดง popularity
# app/controllers/articles_controller.rb class ArticlesController < ApplicationController def index @articles = Article.all end # ... endเราไม่ต้อง preload responses เพราะเราไม่ show data ของพวกมันบน index page เราแสดง counter ดังนั้นเราสนใจแค่จำนวน responses สำหรับแต่ละ article Controller หา articles ทั้งหมดและวางใน variable @articles สำหรับ view ใช้
Articles
<% @articles.each do |article| %> <%= article.title %>
<%= article.description %>
<%= article.responses.size %> responses <% end %>View loop over แต่ละ article และ render title, description และจำนวน responses ที่ได้รับ เพราะเราเรียก article.responses.size ใน view ActiveRecord รู้ว่าต้อง count association แทนที่จะ load whole record สำหรับแต่ละ response
Tip: แม้ #count จะ聽起來 intuitive สำหรับนับ responses ตัวอย่างนี้ใช้ #size เพราะ #count จะทำ COUNT query เสมอ ในขณะที่ #size จะข้าม query ถ้า 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]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 4]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 5]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 6]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 7]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 8]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 9]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 10]] ↳ app/views/articles/index.html.erb:7 (0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 11]] ↳ app/views/articles/index.html.erb:7 Rendered articles/index.html.erb within layouts/application (23.1ms) Completed 200 OK in 52ms (Views: 45.7ms | ActiveRecord: 1.6ms) Request blog's index ทำให้เกิด N+1 queries เพราะ ActiveRecord lazy-loads response count สำหรับแต่ละ article ใน query แยกกัน
ใช้ COUNT() จาก query
เพื่อหลีกเลี่ยง extra query ต่อ article เราสามารถ join articles และ responses tables เข้าด้วยกันเพื่อ count associated responses ใน 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ในตัวอย่างนี้ เรา join responses ใน articles query และ select COUNT("responses.id") เพื่อนับจำนวน responses เราจะ group by product IDs เพื่อนับ responses ต่อ article ใน view เราต้องใช้ responses_count แทนการเรียก size บน responses association
Solution นี้ prevent extra queries โดยทำ first query ช้าลงและซับซ้อนขึ้น แม้จะเป็น good first step ในการ optimize performance ของหน้านี้ เราสามารถไปต่อได้อีกขั้นและ cache counter เพื่อไม่ต้องนับแต่ละ response ทุก page view
Counter cache
เนื่องจาก articles บน blog (คาดว่า) ถูกอ่านบ่อยกว่าที่จะ updated counter cache เป็น optimization ที่ดีทำให้ query หน้านี้เร็วและง่ายขึ้น
แทนที่จะนับจำนวน responses ทุกครั้งที่ articles แสดง counter cache เก็บ separate response counter ที่เก็บในแต่ละ article's database row Counter update ทุกครั้งที่ response ถูกเพิ่มหรือลบ
สิ่งนี้ช่วยให้ article index render ด้วย one database query โดยไม่ต้อง join responses ใน query เพื่อ setup ให้ flip switch ใน belongs_to relation โดยตั้งค่า option counter_cache
# app/models/response.rb class Response belongs_to :article, counter_cache: true endสิ่งนี้ต้องการ field ให้ Article model ชื่อ responses_count Option counter_cache ทำให้ number ใน field นั้น update อัตโนมัติเมื่อ response ถูกเพิ่มหรือลบ
Tip: Field name สามารถ override ได้โดยใช้ symbol แทน true เป็น value สำหรับ option counter_cache
เราสร้าง column ใหม่ใน database เพื่อเก็บ 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) ============= เพราะจำนวน responses ตอนนี้ cached ใน articles table เราไม่ต้อง join responses ใน articles query เราจะใช้ Article.all fetch ทุก articles ใน controller
# app/controllers/articles_controller.rb class ArticlesController < ApplicationController def index @articles = Article.all end # ... endเราไม่ต้องเปลี่ยน view เพราะ Rails เข้าใจว่าจะใช้ counter cache สำหรับ method #size
Articles
<% @articles.each do |article| %> <%= article.title %>
<%= article.description %>
<%= article.responses.size %> responses <% end %>Request index อีกครั้ง เราเห็น one query executed เพราะแต่ละ article รู้จำนวน responses ของมัน ไม่ต้อง query responses table เลย
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 สำหรับ scoped associations
ActiveRecord's counter cache callbacks ยิงเฉพาะตอน creating หรือ destroying records ดังนั้นการเพิ่ม counter cache บน scoped association ไม่ได้งาน สำหรับ advanced cases เช่น นับแค่ *published* responses เช็คออก counter_culture gem
Populating the counter cache
สำหรับ articles ที่มีอยู่ก่อน counter cache counter จะ out of sync เพราะเป็น 0 โดยค่าเริ่มต้น เราสามารถ "reset" counter สำหรับ object โดยใช้ method .reset_counters บนมันและส่ง object's ID และ relation ที่ counter ควร update
Article.reset_counters(article.id, :responses)เพื่อ ensure ว่ามันรันบน production เมื่อ deploy เราจะวางมันใน migration ที่รันตรงหลังเพิ่ม column ใน migration ล่าสุด
$ rails generate migration PopulateArticleResponsesCount --force invoke active_record create db/migrate/20180618093443_populate_article_responses_count.rb ใน migration เราจะเรียก Article.reset_counters สำหรับแต่ละ article ส่ง articles' IDs และ :responses เป็น association's 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 endMigration นี้ update counts สำหรับทุก articles ใน database รวมถึงที่มีอยู่ก่อน counter cache
Callbacks
เพราะ counter caches ใช้ callbacks update counters methods ที่ execute SQL commands โดยตรง (เหมือนเมื่อใช้ #delete แทน #destroy) จะไม่ update counters
ใน situations ที่เกิดขึ้นเพราะบางเหตุผล อาจมีความหมายที่จะ add Rake task หรือ 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 endCached counters
การ prevent N+1 queries โดยนับ associated objects ใน query ช่วยได้ แต caching counters เป็น even faster way เพื่อ show counters สำหรับ most applications ActiveRecord's built-in cached counters ช่วยได้มาก และ options เหมือน counter_culture สามารถใช้สำหรับ requirements ที่ elaborate กว่า
มีคำถามเกี่ยวกับ ActiveRecord's counter caches? อย่าลังเลบอกเราที่ @AppSignal 當然 เรายินดีรู้ว่าคุณชอบบทความนี้ไหม หรือมี subject อื่นที่อยากรู้เพิ่มเติม