Database คือหัวใจของหลาย applications ปัญหากับมันอาจทำ performance issues ร้ายแรง
ORMs เช่น ActiveRecord และ Mongoid ช่วย abstract implementation ส่งมอบโค้ดเร็ว แต่บางครั้งลืมเช็ค queries ที่รันใต้ฮู้ด
Gem bullet ช่วย identify database-related problems ที่รู้จักดี:
- "N+1 Queries": app รัน query load แต่ละ item ของ list
- "Unused Eager Loading": app load data (ปกติเพื่อกัน N+1) แต่ไม่ได้ใช้
- "Missing Counter Cache": app ต้อง execute count queries เพื่อได้จำนวน associated items
ในโพสต์นี้ จะแสดง:
- configure
bulletgem ใน Ruby project - ตัวอย่างแต่ละปัญหา
- bullet detect ยังไง
- fix ยังไง
- integrate
bulletกับ AppSignal
ใช้ตัวอย่างจาก project ที่สร้างสำหรับโพสต์นี้
Configure Bullet ใน Ruby Project
เพิ่ม gem ใน Gemfile
เพิ่มในทุก environments ได้ เรา enable/disable และใช้ approach ต่างๆ ต่อ environment:
gem 'bullet' Configure it
Rails project รัน command generate config อัตโนมัติ:
bundle exec rails g bullet:install Non-Rails project เพิ่ม manual เช่นใน spec_helper.rb หลัง load app code:
Bullet.enable = true Bullet.bullet_logger = true Bullet.raise = true และใน main file หลัง load app code:
Bullet.enable = true รายละเอียด config อื่นๆ ใน README ของ bullet
ใช้ Bullet ใน Tests
Config ที่แนะนำ Bullet detect bad queries ใน tests 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 Posts
Name Comments <% @posts.each do |post| %> <%= post.name %> <%= post.comments.map(&:name) %> <% end %>
bullet raise error detect "N+1" ตอนรัน 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:
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 ' เพราะ view execute query load comment name แต่ละอันใน 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]] Fix: follow error message เพิ่ม .includes([:comments]):
-@posts = Post.all +@posts = Post.all.includes([:comments]) ActiveRecord load comments ทั้งหมดด้วย 1 query:
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 แต่ bullet ไม่ raise exception ใน controller test (ไม่ render views by default → N+1 ไม่ trigger)
Note: controller tests discouraged since 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 View test ก็ไม่ detect N+1 เพราะไม่ run N+1 queries ใน database:
# 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 Tip: More Chances Detect N+1 ใน Tests
แนะนำสร้างอย่างน้อย 1 request spec ต่อ controller action เพื่อ test HTTP status ให้ถูกต้อง bullet จะ watch queries ตอน 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:
# app/views/posts/basic_index.html.erb Posts
Name <% @posts.each do |post| %> <%= post.name %> <% end %>
Test:
# 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 /Users/fabioperrella/projects/bullet-test/spec/requests/posts_request_spec.rb:20:in `block (3 levels) in ' เพราะไม่จำเป็น load comments list สำหรับ view นี้
Fix: follow error ลบ .includes([:comments]):
-@posts = Post.all.includes(:comments) +@posts = Post.all Controller test โดยไม่มี render_views ก็ไม่ raise error เหมือนกัน
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 Posts
Name Number of comments <% @posts.each do |post| %> <%= post.name %> <%= post.comments.size %> <% end %>
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 ' เพราะ view execute 1 query count comments ใน post.comments.size ต่อ post:
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]] Fix: สร้าง counter cache อาจซับซ้อนถ้ามี data ใน production
Counter cache = column เพิ่มใน table ActiveRecord update อัตโนมัติตอน insert/delete associated models รายละเอียดในโพสต์นี้ อ่านต่อเพื่อสร้างและ sync counter cache
ใช้ Bullet ใน Development
Tests อาจไม่ detect problems (เช่น low test coverage) enable bullet ใน environments อื่นได้
Development environment enable configs:
Bullet.alert = true แสดง alerts ใน browser
Bullet.add_footer = true เพิ่ม footer บนหน้าพร้อม error
Bullet.console = true Error ใน browser console
ใช้ Bullet ใน Staging กับ AppSignal
Staging ไม่อยาก show error messages ให้ end-users แต่อยากรู้ถ้า app เริ่มมีปัญหา
Bullet อาจ degrade performance และเพิ่ม memory consumption แนะนำ enable ชั่วคราวใน staging แต่อย่า enable ใน production
สมมติ staging ใช้ config file เหมือน production (good practice ลดความต่าง) ใช้ env variable enable/disable bullet:
# config/environments/production.rb config.after_initialize do Bullet.enabled = ENV.fetch('BULLET_ENABLED', false) Bullet.appsignal = true end รับ notifications ปัญหาที่ Bullet หาใน staging ผ่าน AppSignal report เป็น errors ต้องมี appsignal gem installed และ configured ดูรายละเอียดใน Ruby gem docs
ถ้า bullet detect ปัญหา จะสร้าง error incident:
Error นี้ raised by uniform_notifier gem ที่ extracted จาก bullet
น่าเสียดาย error message ไม่ show enough info แต่ผมส่ง PR improve แล้ว!
สรุป
bullet gem = tool ยอดเยี่ยม detect problems degrade performance ใน apps
พยายามรักษา test coverage ให้ดี เพื่อ detect problems ก่อน production
Extra tip: อยาก protected จาก performance problems database มากกว่านี้ ดู wt-activerecord-index-spy gem ช่วย detect queries ไม่ใช้ proper indexes
P.S. อยากอ่าน Ruby Magic posts ทันทีเผยแพร่? สมัครรับ Ruby Magic newsletter!