Tìm hiểu về N + 1 Query

Rails có thể nói là một sự lựa chọn phổ biến nhất khi bạn muốn phát triển một sản phẩm có thể sử dụng được ở mức độ vừa phải nhất (Minimum Viable Products). Bạn có thể sử dụng bootstrap, prototype, và phát hành một ứng dụng rất dễ dàng với Rails, và nó cũng là sự lựa chọn của rất nhiều lập trình viên.

Nhưng thường thì trong quá trình phát triển sản phẩm, hầu hết các lập trình viên chưa chú trọng đến việc tăng cường performance cho hệ thống mà thực ra dù sao cũng chưa nên để ý nhiều ở giai đoạn đó. Nhưng khi sản phẩm trong quá trình hoàn thiện và cần được thu nhỏ cũng như tối ưu, các chỉ số hiệu suất này bắt đầu đóng vai trò quan trọng. Lập trình viên cần cân nhắc tối ưu hoá tốc độ xử lý cũng như không làm thay đổi logic của các dòng code.

Một điểm cần được tối ưu đó là những truy vấn mà application của bạn gửi tới database, tốc độ của sản phẩm sẽ tăng lên khi số lượng các truy vấn được giảm đi.

Phần lớn các application dùng Rails đều có dữ liệu được phân bổ qua các model và các mối quan hệ giữa chúng và sử dụng các ORM để truy vấn. Các ORM đó có thể giúp phát hiện các sai lệch giữa các mối quan hệ trong database với các mô hình hướng đối tượng, hi vọng sẽ giúp mọi thứ dễ dàng hơn. Nhưng nếu không ý thức để tránh được một số "cạm bẫy" có thể làm cho tốc độ xử lý của Application giảm xuống một cách khủng khiếp. Một trong những "cạm bẫy" đó chính là vấn đề gửi quá nhiều truy vấn (N+1 queries).

Vậy N+1 Query là gì ?

Vấn đề xuất hiện khi cần load dữ liệu của một model "con" trong một mối quan hệ parent-child (hay phần "nhiều" trong quan hệ một-nhiều). Phần lớn các ORM mặc định kích hoạt chức năng lazy-loading, nên các truy vấn được tìm tới bản ghi "cha", sau đó mới thực hiện từng truy vấn đối với các bản ghi "con". Bạn có thể nhận ra rằng, thực hiện N+1 truy vấn thay vì 1 truy vấn sẽ có thể làm tràn bộ nhớ của database, điều này đương nhiên chúng ta có thể và nên tránh.

Giả sử như một blog đơn giản có rất nhiều bài báo được viết bởi các tác giả khác nhau:

 #Articles model
class Article < ActiveRecord::Base
  belongs_to :author
end

#Authors model
class Author < ActiveRecord::Base
  has_many :posts
end

Ta muốn lấy ra 5 bài báo mới nhất cùng với tiêu đề và tên tác giả của các bài báo đó.

Chúng ta có thể làm như sau:

#In our controller
@recent_articles = Article.order(published_at: :desc).limit(5)

#in our view file
@recent_articles.each do |article|
    Title: <%= article.title %>
    Author:<%= article.author.name %>
end

Dòng code trên sẽ gửi 6 (5+1) truy vấn đến database, 1 truy vấn để lấy 5 bài báo mới nhất và 5 truy vấn để lấy tên tác giả cho từng bài báo. Trong trường hợp trên, chúng ta chỉ giới hạn số lượng truy vấn ở 5 nên sẽ không thể nhìn thấy được vấn đề này ảnh hưởng đến tốc độ xử lý một cách rõ ràng. Nhưng nếu tăng số lượng truy vấn lên, không biết điều gì có thể xảy ra.

Mỗi truy vấn sẽ tốn một chút tài nguyên. Sẽ tốt hơn nếu chúng ta đưa ra 1 truy vấn và có được 100 kết quả, hơn là đưa ra 100 truy vấn và cũng chỉ có 100 kết quả. Điều này đặc biệt có ý nghĩa khi database được đặt riêng biệt và có sự tồn tại độ lag giữa application và server.

Giải pháp - Eager Loading

Eager Loading là một cơ chế cho phép load các bản ghi có liên quan đến đối tượng được trả về bởi

Model.find

và sử dụng số truy vấn ít nhất có thể. Ở ví dụ trên, nếu chúng ta sử dụng eager loading để lấy dữ liệu, thông tin tác giả sẽ được load cùng với bài báo.

Trong Rails, ActiveRecord có một phương thức là includes, nó đảm bảo rằng các dữ liệu liên quan được load với số lượng truy vấn ít nhất có thể.

Hãy tối ưu dòng code của chúng ta sử dụng cơ chế Eager loading :

#In our controller
#Using includes(:authors) will include authors model.
@recent_articles = Article.order(published_at: :desc).includes(:authors).limit(5)

#in our view file
@recent_articles.each do |article|
    Title: <%= article.title %>
    Author:<%= article.author.name %>
end

Eager Loading xử lý vấn đề N+1 như thế nào ?

Truy vấn của chúng ta khi chưa dùng Eager Loading sẽ như sau :

 Article Load (0.9ms) SELECT 'articles'.* FROM 'articles'
Author Load (0.4ms) SELECT 'authors'.* FROM 'authors' WHERE 'authors'.'id' = ? ORDER BY 'authors'.'id' ASC LIMIT 1 [["id", 1]]
Author Load (0.3ms) SELECT 'authors'.* FROM 'authors' WHERE 'authors'.'id' = ? ORDER BY 'authors'.'id' ASC LIMIT 1 [["id", 2]]
Author Load (0.4ms) SELECT 'authors'.* FROM 'authors' WHERE 'authors'.'id' = ? ORDER BY 'authors'.'id' ASC LIMIT 1 [["id", 3]]
Author Load (0.3ms) SELECT 'authors'.* FROM 'authors' WHERE 'authors'.'id' = ? ORDER BY 'authors'.'id' ASC LIMIT 1 [["id", 4]]
Author Load (0.4ms) SELECT 'authors'.* FROM 'authors' WHERE 'authors'.'id' = ? ORDER BY 'authors'.'id' ASC LIMIT 1 [["id", 5]]

Khi đã xử lý Eager Loading:

Article Load (0.4ms) SELECT 'articles'.* FROM 'articles'
Author Load (0.4ms) SELECT 'authors'.* FROM 'authors' WHERE 'authors'.'id' IN (1,2,3,4,5)

số lượng truy vấn đã giảm

Bullet Gem

Bullet Gem được viết bởi Richard Huang, giúp giảm số lượng truy vấn mà Application đưa ra lần đầu tiên vào năm 2009 dưới dạng plugin, và cho tới giờ nó vẫn là một gem rất hữu ích trong việc giám sát tốc độ xử lí của Application. Bullet giúp bạn phát hiện những truy vấn gây nên N+1 và báo cho bạn biết. Điều thú vị hơn là nó còn giúp bạn phát hiện những Eager Load thừa không được dùng đến.

Bullet có rất nhiều cách để thông báo cho bạn biết vị trí gây nên N+1: Growl notifications, JavaScript alerts hay XMPP. Thêm nữa nó còn ghi lại log rất cụ thể trong file bullet.log, và nó cũng có thể xuất log cho application nếu bạn mong muốn.

Cách cài đặt và sử dụng

Thêm vào Gemfile và chạy bundle install:

 group :development do
  gem 'bullet'
end

Gem này chỉ nên được sử dụng, vì chắc hẳn bạn ko muốn người dùng application của bạn nhận được thông báo về N+1 phải không?

Tiếp theo đó là cấu hình để Bullet thông báo cho bạn biết

 config.after_initialize do
  #Enable bullet in your application
  Bullet.enable = true
end

Thông báo qua Javascript:

Bullet có thể được cấu hình để thông báo cho lập trình viên thông qua một alert box đơn giản trong khi đang load trang web bất kì, hãy thêm dòng code sau đây:

Bullet.alert = true

Thông báo qua console:

Nếu bạn không muốn nhìn alert box trên trang web, bạn có thể sử dụng console để biết được tại đâu xảy ra N+1 bằng cách thêm đoạn code sau:

Bullet.console= true

Thông báo qua Rails Logs:

Bullet cũng có thể lưu lại logs về các truy vấn chưa được tối ưu. Vì vậy, nếu bạn đang sử dụng một số công cụ để phân tích cho các logs, bạn có thể thêm đoạn code sau: Bullet.rails_logger = true

Ghi lại log:

Nếu bạn muốn các truy vấn được log lại, Bullet có thể giúp bạn bằng cách:

Bullet.bullet_logger = true

sẽ lưu lại một file log với tên bullet.log với tất cả những truy vấn chưa được tối ưu hoá.

Thông báo qua Growl Notifications:

Bullet.growl = true

Tham khảo

Rubygems : https://rubygems.org/gems/bullet

Github : http://github.com/flyerhzm/bullet

RailsCast : http://railscasts.com/episodes/372-bullet