Cơ sở dữ liệu là trung tâm của nhiều ứng dụng và việc gặp sự cố với nó có thể dẫn đến các vấn đề nghiêm trọng về hiệu suất.
Các ORM như ActiveRecord và Mongoid giúp chúng tôi triển khai trừu tượng và phân phối mã nhanh hơn, nhưng đôi khi, chúng tôi quên kiểm tra xem những truy vấn nào đang chạy ẩn.
Viên đá quý giúp chúng tôi xác định một số vấn đề nổi tiếng liên quan đến cơ sở dữ liệu:
- "N + 1 Truy vấn":khi ứng dụng chạy một truy vấn để tải từng mục của danh sách
- "Đang tải hứng thú không được sử dụng":khi ứng dụng tải dữ liệu, thường để tránh N + 1 truy vấn nhưng không sử dụng nó
- "Thiếu bộ nhớ cache của bộ đếm":khi ứng dụng cần thực hiện các truy vấn đếm để lấy số lượng các mục được liên kết
Trong bài đăng này, tôi sẽ trình bày:
- cách định cấu hình
bullet
đá quý trong một dự án Ruby, - ví dụ về từng vấn đề được đề cập trước đây,
- how
bullet
phát hiện từng, - cách khắc phục từng vấn đề và
- cách tích hợp
bullet
với AppSignal.
Tôi sẽ sử dụng một số ví dụ từ một dự án mà tôi đã tạo cho bài đăng này.
Cách định cấu hình Bullet trong Ruby Project
Đầu tiên, thêm đá quý vào Gemfile
.
Chúng tôi có thể thêm nó vào tất cả các môi trường nhất định, chúng tôi có thể bật hoặc tắt nó và sử dụng một cách tiếp cận khác nhau trên mỗi môi trường:
gem 'bullet'
Tiếp theo, cần phải định cấu hình nó.
Nếu bạn đang ở trong một dự án Rails, bạn có thể chạy lệnh sau để tạo mã cấu hình tự động:
bundle exec rails g bullet:install
Nếu bạn đang ở trong một dự án không phải Rails, bạn có thể thêm nó theo cách thủ công, ví dụ:bằng cách thêm mã sau vào spec_helper.rb
sau khi tải mã của ứng dụng:
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true
Và thêm mã sau vào tệp chính sau khi tải mã của ứng dụng:
Bullet.enable = true
Tôi sẽ chia sẻ thêm chi tiết về các cấu hình trong bài đăng này. Nếu bạn muốn xem tất cả, hãy truy cập trang README của dấu đầu dòng.
Sử dụng dấu đầu dòng Trong Thử nghiệm
Với cấu hình được đề xuất trước đó, Bullet sẽ phát hiện các truy vấn không hợp lệ được thực thi trong các thử nghiệm và đưa ra các ngoại lệ cho chúng.
Bây giờ, hãy xem một số ví dụ.
Phát hiện N + 1 Truy vấn
Đưa ra một index
hành động như sau:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
Và một chế độ xem như thế này:
# app/views/posts/index.html.erb
<h1>Posts</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= post.name %></td>
<td><%= post.comments.map(&:name) %></td>
</tr>
<% end %>
</tbody>
</table>
bullet
sẽ phát hiện lỗi phát hiện "N + 1" khi chạy thử nghiệm tích hợp thực thi mã từ chế độ xem và bộ điều khiển, ví dụ:sử dụng thông số yêu cầu như sau:
# spec/requests/posts_request_spec.rb
require 'rails_helper'
RSpec.describe "Posts", type: :request do
describe "GET /index" do
it 'lists all posts' do
post1 = Post.create!
post2 = Post.create!
get '/posts'
expect(response.status).to eq(200)
end
end
end
Trong trường hợp này, nó sẽ nâng cao ngoại lệ này:
Failures:
1) Posts GET /index lists all posts
Failure/Error: get '/posts'
Bullet::Notification::UnoptimizedQueryError:
user: fabioperrella
GET /posts
USE eager loading detected
Post => [:comments]
Add to your query: .includes([:comments])
Call stack
/Users/fabioperrella/projects/bullet-test/app/views/posts/index.html.erb:17:in `map'
...
# ./spec/requests/posts_controller_spec.rb:9:in `block (3 levels) in <top (required)>'
Điều này xảy ra vì chế độ xem đang thực hiện một truy vấn để tải từng tên nhận xét trong post.comments.map(&:name)
:
Processing by PostsController#index as HTML
Post Load (0.4ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
Comment Load (0.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:17:in `map'
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
Để khắc phục, chúng ta chỉ cần làm theo hướng dẫn trong thông báo lỗi và thêm .includes([:comments])
cho truy vấn:
-@posts = Post.all
+@posts = Post.all.includes([:comments])
Thao tác này sẽ hướng dẫn ActiveRecord tải tất cả các nhận xét chỉ với 1 truy vấn.
Processing by PostsController#index as HTML
Post Load (0.2ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
Comment Load (0.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?) [["post_id", 1], ["post_id", 2]]
↳ app/views/posts/index.html.erb:14
Tuy nhiên, bullet
sẽ không đưa ra ngoại lệ trong kiểm tra bộ điều khiển như sau, bởi vì kiểm tra bộ điều khiển không hiển thị chế độ xem theo mặc định, vì vậy truy vấn N + 1 sẽ không được kích hoạt.
Lưu ý:không khuyến khích kiểm tra bộ điều khiển kể từ Rails 5:
# spec/controllers/posts_controller_spec.rb
require 'rails_helper'
RSpec.describe PostsController do
describe 'GET index' do
it 'lists all posts' do
post1 = Post.create!
post2 = Post.create!
get :index
expect(response.status).to eq(200)
end
end
end
Một ví dụ khác về kiểm tra Bullet sẽ không phát hiện "N + 1" là kiểm tra chế độ xem bởi vì, trong trường hợp này, nó sẽ không chạy N + 1 truy vấn trong cơ sở dữ liệu:
# spec/views/posts/index.html.erb_spec.rb
require 'rails_helper'
describe "posts/index.html.erb" do
it 'lists all posts' do
post1 = Post.create!(name: 'post1')
post2 = Post.create!(name: 'post2')
assign(:posts, [post1, post2])
render
expect(rendered).to include('post1')
expect(rendered).to include('post2')
end
end
Mẹo để có thêm cơ hội phát hiện điểm N + 1 trong các bài kiểm tra
Tôi khuyên bạn nên tạo ít nhất 1 thông số yêu cầu cho mỗi hành động của bộ điều khiển, chỉ để kiểm tra xem nó có trả về trạng thái HTTP chính xác hay không, sau đó chọn bullet
sẽ xem các truy vấn khi hiển thị các chế độ xem này.
Phát hiện Đang tải tin nhắn không sử dụng
Đưa ra basic_index
sau hành động:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def basic_index
@posts = Post.all.includes(:comments)
end
end
Và basic_index
sau xem:
# app/views/posts/basic_index.html.erb
<h1>Posts</h1>
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= post.name %></td>
</tr>
<% end %>
</tbody>
</table>
Khi chúng tôi chạy thử nghiệm sau:
# spec/requests/posts_request_spec.rb
require 'rails_helper'
RSpec.describe "Posts", type: :request do
describe "GET /basic_index" do
it 'lists all posts' do
post1 = Post.create!
post2 = Post.create!
get '/posts/basic_index'
expect(response.status).to eq(200)
end
end
end
Dấu đầu dòng sẽ gây ra lỗi sau:
1) Posts GET /basic_index lists all posts
Failure/Error: get '/posts/basic_index'
Bullet::Notification::UnoptimizedQueryError:
user: fabioperrella
GET /posts/basic_index
AVOID eager loading detected
Post => [:comments]
Remove from your query: .includes([:comments])
Call stack
/Users/fabioperrella/projects/bullet-test/spec/requests/posts_request_spec.rb:20:in `block (3 levels) in <top (required)>'
Điều này xảy ra vì không cần thiết phải tải danh sách nhận xét cho chế độ xem này.
Để khắc phục sự cố, chúng tôi chỉ cần làm theo hướng dẫn trong lỗi ở trên và xóa truy vấn .includes([:comments])
:
-@posts = Post.all.includes(:comments)
+@posts = Post.all
Điều đáng nói là nó sẽ không gây ra lỗi tương tự nếu chúng tôi chỉ chạy thử nghiệm bộ điều khiển mà không có render_views
, như được hiển thị trước đây.
Phát hiện bộ nhớ cache của bộ đếm bị thiếu
Đưa ra một bộ điều khiển như thế này:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index_with_counter
@posts = Post.all
end
end
Và một chế độ xem như thế này:
# app/views/posts/index_with_counter.html.erb
<h1>Posts</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Number of comments</th>
</tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= post.name %></td>
<td><%= post.comments.size %></td>
</tr>
<% end %>
</tbody>
</table>
Nếu chúng tôi chạy thông số yêu cầu sau:
describe "GET /index_with_counter" do
it 'lists all posts' do
post1 = Post.create!
post2 = Post.create!
get '/posts/index_with_counter'
expect(response.status).to eq(200)
end
end
bullet
sẽ phát sinh lỗi sau:
1) Posts GET /index_with_counter lists all posts
Failure/Error: get '/posts/index_with_counter'
Bullet::Notification::UnoptimizedQueryError:
user: fabioperrella
GET /posts/index_with_counter
Need Counter Cache
Post => [:comments]
# ./spec/requests/posts_request_spec.rb:31:in `block (3 levels) in <top (required)>'
Điều này xảy ra vì chế độ xem này đang thực hiện 1 truy vấn để đếm số lượng nhận xét trong post.comments.size
cho mỗi bài đăng.
Processing by PostsController#index_with_counter as HTML
↳ app/views/posts/index_with_counter.html.erb:14
Post Load (0.4ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index_with_counter.html.erb:14
(0.4ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index_with_counter.html.erb:17
(0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
Để khắc phục điều này, chúng tôi có thể tạo bộ đệm ẩn bộ đếm, có thể hơi phức tạp, đặc biệt nếu có dữ liệu trong cơ sở dữ liệu sản xuất.
Bộ đệm bộ đếm là một cột mà chúng ta có thể thêm vào bảng, ActiveRecord đó sẽ tự động cập nhật khi chúng ta chèn và xóa các mô hình được liên kết. Có nhiều chi tiết hơn trong bài đăng này. Tôi khuyên bạn nên đọc nó để biết cách tạo và đồng bộ bộ nhớ đệm của bộ đếm.
Sử dụng Bullet trong phát triển
Đôi khi, các thử nghiệm có thể không phát hiện ra các vấn đề đã đề cập trước đó, ví dụ:nếu mức độ phù hợp của thử nghiệm thấp, vì vậy có thể bật bullet
trong các môi trường khác bằng cách sử dụng các cách tiếp cận khác nhau.
Trong môi trường phát triển, chúng ta có thể kích hoạt các cấu hình sau:
Bullet.alert = true
Sau đó, nó sẽ hiển thị các cảnh báo như thế này trong trình duyệt:
Bullet.add_footer = true
Nó sẽ thêm một chân trang trên trang có lỗi:
Cũng có thể kích hoạt lỗi khi đăng nhập vào bảng điều khiển của trình duyệt:
Bullet.console = true
Nó sẽ thêm một lỗi như sau:
Sử dụng Bullet trong Staging với Appsignal
Trong dàn dựng môi trường, chúng tôi không muốn những thông báo lỗi này hiển thị cho người dùng cuối, nhưng sẽ thật tuyệt nếu biết ứng dụng bắt đầu có một trong những sự cố đã đề cập trước đây.
Đồng thời, bullet
có thể làm giảm hiệu suất và tăng mức tiêu thụ bộ nhớ trong ứng dụng, vì vậy tốt hơn là chỉ nên bật nó tạm thời trong staging , nhưng không bật nó trong sản xuất .
Giả sử dàn dựng môi trường đang sử dụng cùng một tệp cấu hình với production môi trường, là một phương pháp hay để giảm sự khác biệt giữa chúng, chúng ta có thể sử dụng một biến môi trường để bật hoặc tắt bullet
như sau:
# config/environments/production.rb
config.after_initialize do
Bullet.enabled = ENV.fetch('BULLET_ENABLED', false)
Bullet.appsignal = true
end
Để nhận thông báo về các vấn đề Bullet đã tìm thấy trong môi trường dàn dựng của bạn, bạn có thể sử dụng AppSignal để báo cáo các thông báo đó là lỗi. Bạn sẽ cần có appsignal
gem được cài đặt và cấu hình trong dự án của bạn. Bạn có thể xem thêm chi tiết trong tài liệu về đá quý Ruby.
Sau đó, nếu sự cố được phát hiện bởi bullet
, nó sẽ tạo ra một sự cố lỗi như sau:
Lỗi này được tạo ra bởi đá quý Uniform_notifier được trích xuất từ bullet
.
Rất tiếc, thông báo lỗi không hiển thị đủ thông tin, nhưng tôi đã gửi Yêu cầu kéo để cải thiện điều này!
Kết luận
Dấu đầu dòng bullet
gem là một công cụ tuyệt vời có thể giúp chúng tôi phát hiện các vấn đề làm giảm hiệu suất trong các ứng dụng.
Cố gắng duy trì phạm vi kiểm tra tốt, như đã đề cập trước đây, để có nhiều cơ hội phát hiện những vấn đề này hơn trước khi đưa vào sản xuất.
Một mẹo bổ sung, nếu bạn muốn được bảo vệ nhiều hơn trước các vấn đề về hiệu suất liên quan đến cơ sở dữ liệu, hãy xem viên ngọc wt-activerecord-index-spy, giúp phát hiện các truy vấn không sử dụng chỉ mục thích hợp.
Tái bút. Nếu bạn muốn đọc các bài đăng của Ruby Magic ngay khi chúng xuất hiện trên báo chí, hãy đăng ký bản tin Ruby Magic của chúng tôi và không bao giờ bỏ lỡ một bài đăng nào!