Counter Cache trong rails

I. Giới thiệu

Bạn đã bao giờ đếm số lượng từ một ActiveRecordRelation ở trong rails và nhìn vào console log, bạn thấy vấn đề n+1 queries đập vào mắt. Bạn đã khác phục nó như thế nào, bạn có thể viết 1 scope loằng ngoằng cho cái việc đó hoặc sử dụng EagerLoading, nhưng nếu bạn đoán được nó sẽ xảy ra trong tương lai ở thời điểm bạn mới bắt đầu xây dựng các table ở trong dự án, sau đây tôi sẽ chỉ cho bạn cách dùng counter cache như thế nào.

II. Cách sử dụng

Ta sẽ tạo 1 ứng dụng mới để thử: Giả sử Project của chúng ta đã có 2 thực thể là projecttask Quan hệ của chúng là 1 project thì có nhiều task

class Project < ApplicationRecord
  has_many :tasks  
end

Ta có hàm index trong ProjectsController như sau:

def index
  @projects = Project.all
end

Trong file projects/index.html.erb ta đếm số lượng task của mỗi project như sau:

<% @projects.each do |project| %>
  <tr>
    <td><%= project.name %></td>
    <td><%= pluralize(project.tasks.size, 'task') %></td>
  </tr>
<% end %>

Và sau đó chúng ta truy cập vào trang: http://localhost:3000/projects Và hãy nhìn vào rails console log:

Started GET "/projects" for ::1 at 2016-03-16 23:47:08 -0700
  ActiveRecord::SchemaMigration Load (0.2ms)  SELECT "schema_migrations".* FROM "schema_migrations"
Processing by ProjectsController#index as HTML
  Project Load (0.1ms)  SELECT "projects".* FROM "projects"
   (0.2ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."project_id" = ?  [["project_id", 1]]
   (0.1ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."project_id" = ?  [["project_id", 2]]
   (0.1ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."project_id" = ?  [["project_id", 3]]
  Rendered projects/index.html.erb within layouts/application (25.4ms)
Completed 200 OK in 202ms (Views: 190.5ms | ActiveRecord: 1.3ms)

Và vấn đề n+1 queries đã xảy ra. Chúng ta muốn đếm số lượng trong 1 câu truy vấn duy nhất, sử dụng EagerLoading có thể là 1 cách. Nó sẽ lấy được nhiều cột hơn là đếm. Điều này sẽ sử dụng băng thông và thời gian nhiều hơn. Nhưng cách sau sẽ giảm điều đó. Chúng ta thêm migration như sau : rails g migration add_tasks_count Sửa file migration vừa thêm thành:

class AddTasksCount < ActiveRecord::Migration[5.0]
  def change
    add_column :projects, :tasks_count, :integer, default: 0
  end
end

Và chạy lệnh migrate Sau đó tạo file rake cho counter cache, thêm file task_counter.rake:

desc 'Counter cache for project has many tasks'

task task_counter: :environment do
  Project.reset_column_information
  Project.pluck(:id).find_each do |p|
    Project.reset_counters p.id, :tasks
  end
end

Vùa xong là việc tạo ra việc đếm task của các project có trong database. Phương thức reset_counters tránh được lỗi readonly nếu bạn sử dụng phương thức update_attributes, Điều này làm cho các project cũ trong database có thể cập nhật được đúng số lượng task. Sau đó chúng ta chạy rake task_counter. Sau đó chúng ta có thể bật console lên kiểm tra xem tasks_count đã được cập nhật vào trong database chưa:

p = Project.first
  Project Load (0.2ms)  SELECT  "projects".* FROM "projects" ORDER BY "projects"."id" ASC LIMIT ?  [["LIMIT", 1]]
 => #<Project id: 1, name: "Wealth Building", created_at: "2016-03-16 19:07:26", updated_at: "2016-03-16 20:55:29", tasks_count: 2> 
> Project.last
  Project Load (0.2ms)  SELECT  "projects".* FROM "projects" ORDER BY "projects"."id" DESC LIMIT ?  [["LIMIT", 1]]
 => #<Project id: 3, name: "Cooking", created_at: "2016-03-16 20:06:26", updated_at: "2016-03-16 20:06:26", tasks_count: 0> 

Nó sẽ ra kết quả tương tự bên trên Ở trong file schema.rb bạn có thể nhìn thấy cột tasks_count đã được thêm vào bảng projects

create_table "projects", force: :cascade do |t|
  t.string   "name"
  t.datetime "created_at",              null: false
  t.datetime "updated_at",              null: false
  t.integer  "tasks_count", default: 0
end

Sau đó chúng ta thử áp dụng ở trên views, như sau:

<td><%= pluralize(project.tasks_count, 'task') %></td>

Và hãy F5 trình duyệt rồi nhìn vào rails console log, bạn sẽ thấy:

Started GET "/projects" for ::1 at 2016-03-16 24:00:00 -0700
Processing by ProjectsController#index as HTML
  Project Load (0.1ms)  SELECT "projects".* FROM "projects"
  Rendered projects/index.html.erb within layouts/application (1.6ms)
Completed 200 OK in 48ms (Views: 45.6ms | ActiveRecord: 0.1ms)

Chúng ta sẽ thử thêm 1 task vào trong 1 project ở trong rails console:

Project.last
  Project Load (0.2ms)  SELECT  "projects".* FROM "projects" ORDER BY "projects"."id" DESC LIMIT ?  [["LIMIT", 1]]
 => #<Project id: 3, name: "Cooking", created_at: "2016-03-16 20:06:26", updated_at: "2016-03-16 20:06:26", tasks_count: 0> 
 > p.tasks.create(name: 'Add counter cache')
   (0.1ms)  begin transaction
  SQL (0.7ms)  INSERT INTO "tasks" ("name", "created_at", "updated_at", "project_id") VALUES (?, ?, ?, ?)  [["name", "Add counter cache"], ["created_at", 2016-03-16 21:09:25 UTC], ["updated_at", 2016-03-16 21:09:25 UTC], ["project_id", 1]]
   (0.7ms)  commit transaction
 => #<Task id: 11, name: "Add counter cache", complete: nil, created_at: "2016-03-16 21:09:25", updated_at: "2016-03-16 21:09:25", project_id: 1, priority: nil> 

Reload lại trình duyệt mà xem, task của project cuối sẽ không tăng đâu, bây giờ chúng ta phải fix lỗi đó Trong model Task, chúng ta sửa lại thành

belongs_to :project, counter_cache: true

Và chúng ta thử lại việc ở trên ở trong console:

f = Project.first
  Project Load (0.1ms)  SELECT  "projects".* FROM "projects" ORDER BY "projects"."id" ASC LIMIT ?  [["LIMIT", 1]]
 => #<Project id: 1, name: "Wealth Building", created_at: "2016-03-16 19:07:26", updated_at: "2016-03-16 20:55:29", tasks_count: 2> 
  f.tasks.map(&:id)
  Task Load (0.1ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."project_id" = ?  [["project_id", 1]]
=> [1, 2, 11] 
 > f.name
 => "Wealth Building" 
> Task.destroy(1)
  Task Load (0.1ms)  SELECT  "tasks".* FROM "tasks" WHERE "tasks"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
   (0.0ms)  begin transaction
  SQL (0.6ms)  DELETE FROM "tasks" WHERE "tasks"."id" = ?  [["id", 1]]
   (0.5ms)  commit transaction
 => #<Task id: 1, name: "Get rich quick", complete: false, created_at: "2016-03-16 19:07:26", updated_at: "2016-03-16 19:07:26", project_id: 1, priority: 4> 

Reload lại trình duyệt, nó sẽ ra kết quả đúng cho bạn 😃 .