Eager loading in rails 4

1. Eager loading là gì?

Eager loading is a way to find objects of a certain class and a number of named associations. It is one of the easiest ways to prevent the dreaded N+1 problem in which fetching 100 posts that each need to display their author triggers 101 database queries. Through the use of eager loading, the number of queries will be reduced from 101 to 2.

2. Nói lời tạm biệt với N + 1 query

Cùng xem xét một ví dụ sau:

class Employee < ActiveRecord::Base
  belongs_to :team
end

class Team < ActiveRecord::Base
  has_many :employees
end
employees = Employee.limit(10)
employees.each do |employee|
   puts employee.title.name
end

Nhìn vào đọc code trên, có vẻ như là ổn, tuy nhiên nếu xét về performance, thì điều đó thật tệ. Cùng xem những câu query đã thực hiện:

SELECT  `employees`.* FROM `employees` LIMIT 10
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 1 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 2 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 3 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 4 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 5 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 6 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 7 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 8 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 9 LIMIT 1
SELECT  `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` = 10 LIMIT 1

Rõ ràng chúng ta cần truy xuất vào database nhiều lần để lấy dữ liệu, điều này làm giảm performance của application xuống rất nhiều. Đặc biệt với việc truy xuất dữ liệu lớn thì đó là bài toán lớn cần được tối ưu.

Eager loading sinh ra để giải quyết vấn đề này. Xem xét cùng ví dụ trên và áp dụng eager loading:

employees = Employee.includes(:title).limit(10)
employees.each do |employee|
 puts employee.title.name
end

Cùng xem những query đã thực hiện:

SELECT  `employees`.* FROM `employees` LIMIT 10
SELECT `titles`.* FROM `titles` WHERE `titles`.`deleted_at` IS NULL AND `titles`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

Thật tuyệt vời vì số lượng query đã giảm đi đáng kể, từ N + 1 query và giờ chỉ còn 2 query. Điều này tương đương với time truy xuất databse giảm xuống. Và đương nhiên, hiệu năng của application của chúng ta cũng sẽ tăng lên.

Như vậy, chúng ta có thể sử dụng eager loading để xử lý N + 1 query.

2. Những cách sử dụng eager loading

image

Chúng ta có 3 cách để sử dụng eager loading:

  • includes()
  • preload()
  • eager_load()

Thực tế hầu hết developer đã và đang sử dụng includes(), dường như includes() khá là quen thuộc khi áp dụng eager loading để truy xuất dữ liệu từ database nếu như sử dụng rails với activerecord. Tuy nhiên bạn có biết tại sao đôi lúc chúng ta cần những câu query nhỏ, đẹp hoặc đôi khi lại là một câu query lớn chưa? Và bạn có biết là preload() hoặc eager_load() sẽ giúp chúng ta giải quyết điều đó không? Vậy chúng ta sẽ cùng làm rõ một số khía cạnh của eager loading mà bạn chưa thực sự quen.

Cùng xem xét lại ví dụ trên với 2 models employee, team và có cùng associations như trên. Xét các query sau:

Team.includes(:employees) (q1)
->
Team Load (0.8ms)  SELECT `teams`.* FROM `teams`
Employee Load (0.6ms)  SELECT `employees`.* FROM `employees` WHERE `employees`.`team_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33)

Team.preload(:employees) (q2)
->
Team Load (0.6ms)  SELECT `teams`.* FROM `teams`
Employee Load (0.6ms)  SELECT `employees`.* FROM `employees` WHERE `employees`.`team_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33)

Team.eager_load(:employees) (q3)
->
SQL (7.7ms)  SELECT `teams`.`id` AS t0_r0, `teams`.`name` AS t0_r1, `teams`.`group_id` AS t0_r2, `teams`.`sync_key` AS t0_r3, `teams`.`created_at` AS t0_r4, `teams`.`updated_at` AS t0_r5, `employees`.`id` AS t1_r0, `employees`.`email` AS t1_r1, `employees`.`display_name` AS t1_r2, `employees`.`card_number` AS t1_r3, `employees`.`position` AS t1_r4, `employees`.`uid` AS t1_r5, `employees`.`contract_type` AS t1_r6, `employees`.`identity_id` AS t1_r7, `employees`.`join_date` AS t1_r8, `employees`.`resigned_date` AS t1_r9, `employees`.`deleted_at` AS t1_r10, `employees`.`user_type` AS t1_r11, `employees`.`avatar` AS t1_r12, `employees`.`sync_key` AS t1_r13, `employees`.`created_at` AS t1_r14, `employees`.`updated_at` AS t1_r15, `employees`.`team_id` AS t1_r16, `employees`.`title_id` AS t1_r17 FROM `teams` LEFT OUTER JOIN `employees` ON `employees`.`team_id` = `teams`.`id`

Dễ để nhận thấy sự khác biệt giữa các query trên:

  • Giống nhau giữa q1q2: đều sử dụng các query riêng biệt để lấy dữ liệu
  • Khác biệt rõ ràng giữa q1, q2q3: q3 nhóm lại thành một câu query lớn để lấy dữ liệu
  • Tất nhiên số lượng query của chúng ta đã giảm xuống rất nhiều 😃

Cùng tiếp tục với việc thêm các điều kiện where("employees.position = ?", "manager") khi query:

Team.includes(:employees).where("employees.position = ?", "manager")
->
Team Load (1.0ms)  SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')
Mysql2::Error: Unknown column 'employees.position' in 'where clause': SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')
ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'employees.position' in 'where clause': SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')

Team.preload(:employees).where("employees.position = ?", "manager")
->
Team Load (1.0ms)  SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')
Mysql2::Error: Unknown column 'employees.position' in 'where clause': SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')
ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'employees.position' in 'where clause': SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')

Team.eager_load(:employees).where("employees.position = ?", "manager")
->
SQL (1.0ms)  SELECT `teams`.`id` AS t0_r0, `teams`.`name` AS t0_r1, `teams`.`group_id` AS t0_r2, `teams`.`sync_key` AS t0_r3, `teams`.`created_at` AS t0_r4, `teams`.`updated_at` AS t0_r5, `employees`.`id` AS t1_r0, `employees`.`email` AS t1_r1, `employees`.`display_name` AS t1_r2, `employees`.`card_number` AS t1_r3, `employees`.`position` AS t1_r4, `employees`.`uid` AS t1_r5, `employees`.`contract_type` AS t1_r6, `employees`.`identity_id` AS t1_r7, `employees`.`join_date` AS t1_r8, `employees`.`resigned_date` AS t1_r9, `employees`.`deleted_at` AS t1_r10, `employees`.`user_type` AS t1_r11, `employees`.`avatar` AS t1_r12, `employees`.`sync_key` AS t1_r13, `employees`.`created_at` AS t1_r14, `employees`.`updated_at` AS t1_r15, `employees`.`team_id` AS t1_r16, `employees`.`title_id` AS t1_r17 FROM `teams` LEFT OUTER JOIN `employees` ON `employees`.`team_id` = `teams`.`id` WHERE (employees.position = 'manager')

Thật ngạc nhiên khi mà eager loading sử dụng includes() hoặc preload() đã không run, lỗi đã được raise. Ngược lại eager_load() hoạt động hoàn hảo. Nhưng chờ chút, các bạn hãy cùng xem, nếu thêm conditions references("employees") nối tiếp vào 2 query bị lỗi ở trên thì như thế nào nhỉ. Cùng xem kết quả nhé:

Team.includes(:employees).where("employees.position = ?", "manager").references("employees")
->
SQL (1.0ms)  SELECT `teams`.`id` AS t0_r0, `teams`.`name` AS t0_r1, `teams`.`group_id` AS t0_r2, `teams`.`sync_key` AS t0_r3, `teams`.`created_at` AS t0_r4, `teams`.`updated_at` AS t0_r5, `employees`.`id` AS t1_r0, `employees`.`email` AS t1_r1, `employees`.`display_name` AS t1_r2, `employees`.`card_number` AS t1_r3, `employees`.`position` AS t1_r4, `employees`.`uid` AS t1_r5, `employees`.`contract_type` AS t1_r6, `employees`.`identity_id` AS t1_r7, `employees`.`join_date` AS t1_r8, `employees`.`resigned_date` AS t1_r9, `employees`.`deleted_at` AS t1_r10, `employees`.`user_type` AS t1_r11, `employees`.`avatar` AS t1_r12, `employees`.`sync_key` AS t1_r13, `employees`.`created_at` AS t1_r14, `employees`.`updated_at` AS t1_r15, `employees`.`team_id` AS t1_r16, `employees`.`title_id` AS t1_r17 FROM `teams` LEFT OUTER JOIN `employees` ON `employees`.`team_id` = `teams`.`id` WHERE (employees.position = 'manager')

Team.preload(:employees).where("employees.position = ?", "manager").references("employees")
->
  Team Load (1.1ms)  SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')
Mysql2::Error: Unknown column 'employees.position' in 'where clause': SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')
ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'employees.position' in 'where clause': SELECT `teams`.* FROM `teams` WHERE (employees.position = 'manager')

Dễ nhận thấy includes() đã run và thực sư là query rất giống với eager_load() khi không có thêm conditions. Trong khi đó preload() vẫn không run 😦.

Như vậy chúng ta có thể thấy includes() là trung gian giữa preload() hoặc eager_load, tùy thuộc vào có hay không điều kiện đi kèm với một preloaded table.

preload() sử dụng các query riêng biệt để lấy dữ liệu. eager_load nhóm thành một câu query lớn để lấy dữ liệu. Chúng có thể sử dụng references với includes() nếu như chúng ta có một điều kiện cho một eager loaded table.

3. Eager loading polymorphic

Cùng xem xét ví dụ:

class User < ActiveRecord::Base
  has_many :reviews
end

class Review < ActiveRecord::Base
  belongs_to :user
  belongs_to :reviewable, polymorphic: true
end

class Shop < ActiveRecord::Base
  has_many :reviews, as: :reviewable
end

Bạn sẽ không thể thực hiện eager load vì những nguyên nhân sau:

  • Activerecord sẽ không thể thực hiện được join bảng nếu không có đầy đủ thông tin.
  • Thực tế chúng ta không có bảng dữ liệu nào là reviewable

Vậy để giải quyết vấn đề đó chúng ta association giữa reviewshop như sau:

class Review < ActiveRecord::Base
  belongs_to :user
  belongs_to :reviewable, polymorphic: true
  # For Rails < 4
  belongs_to :shop, foreign_key: "reviewable_id", conditions: "reviews.reviewable_type = 'Shop'"
  # For Rails >= 4
  belongs_to :shop, ->{where reviews: {reviewable_type: ""Shop""}}, foreign_key: "reviewable_id"
end

Và chúng ta có thể sử dụng eager loading:

Review.includes(:shop).where(shops: {shop_type: "cafe"})

4. Tổng kết

Eager loading thực sự là một công cụ bổ ích khi thực hiện tối ưu performace của application.

Bên cạnh đó xin giới thiêu với các bạn một vài công cụ để giúp cho việc optimize performace và clean your code:

  • Gem hỗ trở kiểm tra N+1 query Bullet
  • Gem hỗ trở kiểm tra thời gian thực hiện load trang RACK-MINI-PROFILER