Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Hidden Gem Của Ruby: Bullet – Phát Hiện N+1 & Unused Eager Loading Issues

Database là trái tim nhiều apps, problems với nó → serious performance issues.

ORMs như ActiveRecord, Mongoid giúp abstract implementation, deliver code faster, nhưng đôi khi quên check queries chạy under the hood.

Gem bullet giúp identify well-known DB-related problems:

  1. "N+1 Queries": app chạy query load từng item của list
  2. "Unused Eager Loading": app load data (thường avoid N+1) nhưng không use
  3. "Missing Counter Cache": app execute count queries lấy số associated items

Bài này show:

  • Cách config bullet gem trong Ruby project
  • Ví dụ từng problem
  • Cách bullet detect từng problem
  • Cách fix từng problem
  • Cách integrate bullet với AppSignal

Sử dụng examples từ project tạo cho bài này.

Cách Config Bullet Trong Ruby Project

Thêm gem vào Gemfile:

gem 'bullet'

Có thể add tất cả environments, enable/disable khác nhau mỗi env.

Rails project: chạy command generate config auto:

bundle exec rails g bullet:install

Non-Rails project: add manual, ví dụ spec_helper.rb sau load app code:

Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true

Và main file sau load app code:

Bullet.enable = true

Chi tiết configs hơn trong bài. Xem all: bullet README.

Dùng Bullet Trong Tests

Với config trên, Bullet detect bad queries trong tests và raise exceptions.

Detecting N+1 Queries

index action:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

View:

# 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 raise error detecting "N+1" khi chạy integrated test execute code view + controller (request spec):

# 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

Exception raised:

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)>'

Happens vì view execute 1 query load mỗi comment name trong post.comments.map(&:name):

Processing by PostsController#index as HTML
Post Load (0.4ms) SELECT "posts".* FROM "posts"
Comment Load (0.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]

Fix: follow error instruction, add .includes([:comments]):

-@posts = Post.all
+@posts = Post.all.includes([:comments])

ActiveRecord load all comments với 1 query:

Processing by PostsController#index as HTML
Post Load (0.2ms) SELECT "posts".* FROM "posts"
Comment Load (0.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?) [["post_id", 1], ["post_id", 2]]

bullet không raise exception controller test (không render views default → N+1 không triggered). Controller tests discouraged since Rails 5.

View test cũng không detect N+1 vì không run N+1 queries DB.

Tip: More Chances Detect N+1 In Tests

Recommend tạo ít nhất 1 request spec mỗi controller action, test HTTP status correct → bullet watch queries khi render views.

Detecting Unused Eager Loading

basic_index action:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def basic_index
    @posts = Post.all.includes(:comments)
  end
end

View basic_index (không dùng comments):

# 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>

Test request spec:

# 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

Bullet raise error:

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 ...

Fix: remove .includes([:comments]):

-@posts = Post.all.includes(:comments)
+@posts = Post.all

Không raise error controller test không render_views.

Detecting Missing Counter Cache

Controller:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index_with_counter
    @posts = Post.all
  end
end

View:

# 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>

Request spec:

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 raise error:

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)>'

Happens vì view execute 1 query count comments cho mỗi post (post.comments.size).

Processing by PostsController#index_with_counter as HTML
Post Load (0.4ms) SELECT "posts".* FROM "posts"
(0.4ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
(0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]

Fix: tạo counter cache (phức tạp, nhất là có data production). Counter cache = column added to table, ActiveRecord auto update khi insert/delete associated models. Chi tiết bài này.

Dùng Bullet Trong Development

Tests có thể không detect problems (low test coverage). Enable bullet environments khác với approaches khác.

Development env configs:

Bullet.alert = true

Show alerts browser.

Bullet.add_footer = true

Add footer page với error.

Bullet.console = true

Log errors browser console.

Dùng Bullet Trong Staging Với AppSignal

Staging env: không muốn error messages show end-users, nhưng muốn biết nếu app bắt đầu có problems.

Cùng lúc, bullet có thể degrade performance, increase memory consumption → better enable temporarily staging, KHÔNG enable production.

Giả sử staging dùng same config file production (good practice reduce diff), dùng env variable enable/disable bullet:

# config/environments/production.rb
config.after_initialize do
  Bullet.enabled = ENV.fetch('BULLET_ENABLED', false)
  Bullet.appsignal = true
end

Receive notifications issues Bullet found staging: dùng AppSignal report notifications as errors. Cần appsignal gem installed/configured. Details Ruby gem docs.

Khi problem detected bởi bullet → tạo error incident (bởi uniform_notifier gem extracted từ bullet). Error message không đủ info, nhưng đã send PR improve!

Kết Luận

bullet gem là tool tuyệt vời detect problems degrade performance apps.

Giữ good test coverage để detect problems trước production.

Extra tip: muốn protected hơn performance problems DB-related, xem wt-activerecord-index-spy gem - detect queries không dùng proper indexes.

P.S. Đăng ký Ruby Magic newsletter để đọc sớm nhất!