Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

Ruby's Hidden Gem: Bullet – ตรวจจับ N+1 และ Unused Eager Loading Issues

Database คือหัวใจของหลาย applications ปัญหากับมันอาจทำ performance issues ร้ายแรง

ORMs เช่น ActiveRecord และ Mongoid ช่วย abstract implementation ส่งมอบโค้ดเร็ว แต่บางครั้งลืมเช็ค queries ที่รันใต้ฮู้ด

Gem bullet ช่วย identify database-related problems ที่รู้จักดี:

  1. "N+1 Queries": app รัน query load แต่ละ item ของ list
  2. "Unused Eager Loading": app load data (ปกติเพื่อกัน N+1) แต่ไม่ได้ใช้
  3. "Missing Counter Cache": app ต้อง execute count queries เพื่อได้จำนวน associated items

ในโพสต์นี้ จะแสดง:

  • configure bullet gem ใน 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

<% @posts.each do |post| %> <% end %>
Name Comments
<%= post.name %> <%= post.comments.map(&:name) %>

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

<% @posts.each do |post| %> <% end %>
Name
<%= post.name %>

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

<% @posts.each do |post| %> <% end %>
Name Number of comments
<%= post.name %> <%= post.comments.size %>

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!