ฐานข้อมูลเป็นหัวใจของแอปพลิเคชันจำนวนมาก และการมีปัญหากับฐานข้อมูลอาจส่งผลให้เกิดปัญหาด้านประสิทธิภาพที่ร้ายแรง
ORM เช่น ActiveRecord และ Mongoid ช่วยให้เราใช้งานนามธรรมและส่งมอบโค้ดได้เร็วขึ้น แต่บางครั้ง เราลืมตรวจสอบว่ามีการสืบค้นข้อมูลใดบ้างที่กำลังทำงานอยู่
สัญลักษณ์แสดงหัวข้อย่อยช่วยให้เราระบุปัญหาที่เกี่ยวข้องกับฐานข้อมูลที่รู้จักกันดี:
- "N+1 Queries":เมื่อแอปพลิเคชันเรียกใช้แบบสอบถามเพื่อโหลดแต่ละรายการของรายการ
- "Unused Eager Loading":เมื่อแอปพลิเคชันโหลดข้อมูล โดยปกติเพื่อหลีกเลี่ยงการสืบค้น N+1 แต่ไม่ได้ใช้
- "Missing Counter Cache":เมื่อแอปพลิเคชันจำเป็นต้องดำเนินการค้นหาการนับเพื่อรับจำนวนรายการที่เกี่ยวข้อง
ในโพสต์นี้ ผมจะแสดง:
- วิธีกำหนดค่า
bullet
อัญมณีในโปรเจ็กต์ Ruby - ตัวอย่างของแต่ละปัญหาที่กล่าวถึงก่อนหน้านี้
- อย่างไร
bullet
ตรวจจับแต่ละตัว - วิธีแก้ไขปัญหาแต่ละข้อและ
- วิธีผสาน
bullet
ด้วย AppSignal
ฉันจะใช้ตัวอย่างบางส่วนจากโครงการที่ฉันสร้างขึ้นสำหรับโพสต์นี้
วิธีกำหนดค่า Bullet ในโครงการ Ruby
ขั้นแรก เพิ่มอัญมณีลงใน Gemfile
.
เราสามารถเพิ่มไปยังทุกสภาพแวดล้อมที่กำหนด เราสามารถเปิดหรือปิดการใช้งานมัน และใช้วิธีการที่แตกต่างกันในแต่ละสภาพแวดล้อม:
gem 'bullet'
ถัดไป จำเป็นต้องกำหนดค่า
หากคุณอยู่ในโครงการ Rails คุณสามารถเรียกใช้คำสั่งต่อไปนี้เพื่อสร้างรหัสการกำหนดค่าโดยอัตโนมัติ:
bundle exec rails g bullet:install
หากคุณอยู่ในโครงการที่ไม่ใช่ Rails คุณสามารถเพิ่มได้ด้วยตนเอง เช่น โดยการเพิ่มโค้ดต่อไปนี้ใน spec_helper.rb
หลังจากโหลดโค้ดของแอปพลิเคชัน:
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true
และเพิ่มโค้ดต่อไปนี้ในไฟล์หลักหลังจากโหลดโค้ดของแอปพลิเคชัน:
Bullet.enable = true
ฉันจะแบ่งปันรายละเอียดเพิ่มเติมเกี่ยวกับการกำหนดค่าในโพสต์นี้ หากต้องการดูทั้งหมด ให้ไปที่หน้า README ของสัญลักษณ์แสดงหัวข้อย่อย
การใช้สัญลักษณ์แสดงหัวข้อย่อยในการทดสอบ
ด้วยการกำหนดค่าที่แนะนำก่อนหน้านี้ Bullet จะตรวจจับการสืบค้นที่ไม่ดีในการทดสอบและยกข้อยกเว้นให้
มาดูตัวอย่างกัน
การตรวจจับการสืบค้น N+1
รับ index
ดำเนินการดังนี้:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
และมุมมองเช่นนี้:
# 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
จะทำให้เกิดข้อผิดพลาดในการตรวจหา "N+1" เมื่อเรียกใช้การทดสอบแบบรวมที่รันโค้ดจากมุมมองและตัวควบคุม เช่น ใช้ข้อกำหนดคำขอดังต่อไปนี้:
# 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
ในกรณีนี้ จะทำให้เกิดข้อยกเว้นนี้:
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)>'
สิ่งนี้เกิดขึ้นเนื่องจากมุมมองดำเนินการค้นหาหนึ่งรายการเพื่อโหลดชื่อความคิดเห็นแต่ละรายการใน 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]]
ในการแก้ไข เราสามารถทำตามคำแนะนำในข้อความแสดงข้อผิดพลาดและเพิ่ม .includes([:comments])
ไปที่แบบสอบถาม:
-@posts = Post.all
+@posts = Post.all.includes([:comments])
การดำเนินการนี้จะสั่งให้ ActiveRecord โหลดความคิดเห็นทั้งหมดโดยมีเพียง 1 แบบสอบถาม
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
จะไม่ทำให้เกิดข้อยกเว้นในการทดสอบตัวควบคุมดังต่อไปนี้ เนื่องจากการทดสอบตัวควบคุมไม่แสดงมุมมองโดยค่าเริ่มต้น ดังนั้นการสืบค้น N+1 จะไม่ถูกทริกเกอร์
หมายเหตุ:ไม่แนะนำให้ทดสอบคอนโทรลเลอร์เนื่องจาก 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
อีกตัวอย่างหนึ่งของการทดสอบที่ Bullet จะตรวจไม่พบ "N+1" คือการทดสอบการดู เนื่องจากในกรณีนี้จะไม่เรียกใช้การสืบค้น N+1 ในฐานข้อมูล:
# 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
เคล็ดลับเพื่อเพิ่มโอกาสในการตรวจพบ N+1 ในการทดสอบ
ฉันแนะนำให้สร้างข้อกำหนดคำขออย่างน้อย 1 รายการสำหรับแต่ละการกระทำของตัวควบคุม เพื่อทดสอบว่าส่งคืนสถานะ HTTP ที่ถูกต้องหรือไม่ จากนั้น bullet
จะคอยดูข้อความค้นหาเมื่อแสดงมุมมองเหล่านี้
ตรวจพบการโหลดอย่างกระตือรือร้นที่ไม่ได้ใช้
รับ basic_index
. ต่อไปนี้ การกระทำ:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def basic_index
@posts = Post.all.includes(:comments)
end
end
และ basic_index
. ต่อไปนี้ มุมมอง:
# 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>
เมื่อเรารันการทดสอบต่อไปนี้:
# 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 จะแสดงข้อผิดพลาดต่อไปนี้:
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)>'
สิ่งนี้เกิดขึ้นเพราะไม่จำเป็นต้องโหลดรายการความคิดเห็นสำหรับมุมมองนี้
ในการแก้ไขปัญหา เราสามารถทำตามคำแนะนำในข้อผิดพลาดด้านบนและลบข้อความค้นหา .includes([:comments])
:
-@posts = Post.all.includes(:comments)
+@posts = Post.all
คุ้มที่จะบอกว่ามันจะไม่ทำให้เกิดข้อผิดพลาดแบบเดียวกันถ้าเรารันเฉพาะการทดสอบคอนโทรลเลอร์โดยไม่มี render_views
ดังที่แสดงไว้ก่อนหน้านี้
ตรวจพบแคชตัวนับที่หายไป
รับคอนโทรลเลอร์ดังนี้:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index_with_counter
@posts = Post.all
end
end
และมุมมองเช่นนี้:
# 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>
หากเราเรียกใช้ข้อกำหนดคำขอต่อไปนี้:
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
จะทำให้เกิดข้อผิดพลาดดังต่อไปนี้:
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)>'
สิ่งนี้เกิดขึ้นเนื่องจากมุมมองนี้ดำเนินการค้นหา 1 รายการเพื่อนับจำนวนความคิดเห็นใน post.comments.size
สำหรับแต่ละโพสต์
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]]
ในการแก้ไขปัญหานี้ เราสามารถสร้างแคชตัวนับ ซึ่งอาจซับซ้อนเล็กน้อย โดยเฉพาะอย่างยิ่งหากมีข้อมูลในฐานข้อมูลที่ใช้งานจริง
แคชตัวนับคือคอลัมน์ที่เราสามารถเพิ่มลงในตาราง ซึ่ง ActiveRecord จะอัปเดตโดยอัตโนมัติเมื่อเราแทรกและลบโมเดลที่เกี่ยวข้อง มีรายละเอียดเพิ่มเติมในโพสต์นี้ ฉันแนะนำให้อ่านเพื่อทราบวิธีสร้างและซิงค์แคชตัวนับ
การใช้สัญลักษณ์แสดงหัวข้อย่อยในการพัฒนา
บางครั้ง การทดสอบอาจตรวจไม่พบปัญหาที่กล่าวถึงก่อนหน้านี้ เช่น หากการทดสอบครอบคลุมน้อย จึงสามารถเปิดใช้งาน bullet
ในสภาพแวดล้อมอื่นโดยใช้แนวทางที่แตกต่างกัน
ในสภาพแวดล้อมการพัฒนา เราสามารถเปิดใช้งานการกำหนดค่าต่อไปนี้:
Bullet.alert = true
จากนั้นจะแสดงการแจ้งเตือนในลักษณะนี้ในเบราว์เซอร์:
Bullet.add_footer = true
มันจะเพิ่มส่วนท้ายในหน้าที่มีข้อผิดพลาด:
นอกจากนี้ยังสามารถเปิดใช้งานข้อผิดพลาดเพื่อบันทึกในคอนโซลของเบราว์เซอร์:
Bullet.console = true
มันจะเพิ่มข้อผิดพลาดเช่นนี้:
การใช้ Bullet ในการแสดงละครด้วย Appsignal
ใน การแสดงละคร เราไม่ต้องการให้ข้อความแสดงข้อผิดพลาดเหล่านี้แสดงต่อผู้ใช้ปลายทาง แต่จะเป็นการดีที่จะทราบว่าแอปพลิเคชันเริ่มมีปัญหาอย่างใดอย่างหนึ่งที่กล่าวถึงก่อนหน้านี้หรือไม่
ในขณะเดียวกัน bullet
อาจลดประสิทธิภาพและเพิ่มการใช้หน่วยความจำในแอปพลิเคชัน ดังนั้นจึงควรเปิดใช้งานเพียงชั่วคราวใน staging แต่อย่าเปิดใช้งานใน การผลิต .
สมมติว่า การแสดงละคร สภาพแวดล้อมกำลังใช้ไฟล์การกำหนดค่าเดียวกันกับ การผลิต สภาพแวดล้อม ซึ่งเป็นแนวปฏิบัติที่ดีในการลดความแตกต่างระหว่างกัน เราสามารถใช้ตัวแปรสภาพแวดล้อมเพื่อเปิดใช้งานหรือปิดใช้งาน bullet
ดังนี้
# config/environments/production.rb
config.after_initialize do
Bullet.enabled = ENV.fetch('BULLET_ENABLED', false)
Bullet.appsignal = true
end
ในการรับการแจ้งเตือนเกี่ยวกับปัญหาที่ Bullet พบในสภาพแวดล้อมการแสดงละครของคุณ คุณสามารถใช้ AppSignal เพื่อรายงานการแจ้งเตือนเหล่านั้นว่าเป็นข้อผิดพลาด คุณจะต้องมี appsignal
gem ติดตั้งและกำหนดค่าในโครงการของคุณ คุณสามารถดูรายละเอียดเพิ่มเติมได้ในเอกสาร Ruby gem
จากนั้น หากตรวจพบปัญหาโดย bullet
, มันจะสร้างเหตุการณ์ข้อผิดพลาดเช่นนี้:
ข้อผิดพลาดนี้เกิดจากการที่ gem uniform_notifier ซึ่งดึงมาจาก bullet
.
ขออภัย ข้อความแสดงข้อผิดพลาดแสดงข้อมูลไม่เพียงพอ แต่ฉันส่งคำขอดึงเพื่อปรับปรุงสิ่งนี้!
บทสรุป
bullet
gem เป็นเครื่องมือที่ยอดเยี่ยมที่ช่วยให้เราตรวจพบปัญหาที่จะลดประสิทธิภาพในแอปพลิเคชัน
พยายามรักษาความครอบคลุมของการทดสอบให้ดีดังที่ได้กล่าวไว้ก่อนหน้านี้ เพื่อให้มีโอกาสตรวจพบปัญหาเหล่านี้มากขึ้นก่อนดำเนินการผลิต
สำหรับคำแนะนำเพิ่มเติม หากคุณต้องการได้รับการปกป้องจากปัญหาด้านประสิทธิภาพที่เกี่ยวข้องกับฐานข้อมูลมากขึ้น ให้ดูที่อัญมณี wt-activerecord-index-spy ซึ่งช่วยในการตรวจจับการสืบค้นที่ไม่ได้ใช้ดัชนีที่เหมาะสม
ป.ล. หากคุณต้องการอ่านโพสต์ Ruby Magic ทันทีที่ออกจากสื่อ สมัครรับจดหมายข่าว Ruby Magic ของเราและไม่พลาดแม้แต่โพสต์เดียว!