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:
- "N+1 Queries": app chạy query load từng item của list
- "Unused Eager Loading": app load data (thường avoid N+1) nhưng không use
- "Missing Counter Cache": app execute count queries lấy số associated items
Bài này show:
- Cách config
bulletgem trong Ruby project - Ví dụ từng problem
- Cách
bulletdetect từng problem - Cách fix từng problem
- Cách integrate
bulletvớ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!