Computer >> 컴퓨터 >  >> 프로그램 작성 >> Ruby

루비의 숨겨진 보석:총알

데이터베이스는 많은 애플리케이션의 핵심이며 데이터베이스에 문제가 있으면 심각한 성능 문제가 발생할 수 있습니다.

ActiveRecord 및 Mongoid와 같은 ORM은 구현을 추상화하고 코드를 더 빠르게 전달하는 데 도움이 되지만 때로는 내부에서 실행 중인 쿼리를 확인하는 것을 잊어버립니다.

bullet gem은 잘 알려진 데이터베이스 관련 문제를 식별하는 데 도움이 됩니다.

  1. "N+1 쿼리":애플리케이션이 목록의 각 항목을 로드하기 위해 쿼리를 실행할 때
  2. "Unused Eager Loading":일반적으로 N+1 쿼리를 피하기 위해 애플리케이션이 데이터를 로드하지만 사용하지 않을 때
  3. "카운터 캐시 누락":애플리케이션이 관련 항목 수를 얻기 위해 카운트 쿼리를 실행해야 하는 경우

이 게시물에서 보여줄 내용:

  • bullet를 구성하는 방법 Ruby 프로젝트의 gem,
  • 앞서 언급한 각 문제의 예
  • bullet 방법 각각을 감지하고
  • 각 문제를 해결하는 방법 및
  • bullet 통합 방법 AppSignal과 함께 합니다.

이 게시물을 위해 만든 프로젝트의 몇 가지 예를 사용하겠습니다.

Ruby 프로젝트에서 Bullet을 구성하는 방법

먼저 Gemfile에 gem을 추가합니다. .

주어진 모든 환경에 추가할 수 있고 활성화 또는 비활성화하고 각각에 대해 다른 접근 방식을 사용할 수 있습니다.

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

이 게시물에서 구성에 대한 자세한 내용을 공유하겠습니다. 모두 보고 싶으시면 Bullet의 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가 단 하나의 쿼리로 모든 댓글을 로드하도록 지시합니다.

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을 더 많이 감지할 수 있는 팁

올바른 HTTP 상태를 반환하는지 테스트한 다음 bullet를 반환하는지 테스트하기 위해 각 컨트롤러 작업에 대해 최소 1개의 요청 사양을 만드는 것이 좋습니다. 이 보기를 렌더링할 때 쿼리를 관찰합니다.

사용하지 않는 Eager 로딩 감지

다음 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

글머리 기호는 다음 오류를 발생시킵니다.

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

이것은 이 보기가 post.comments.size의 댓글 수를 계산하기 위해 1개의 쿼리를 실행하기 때문에 발생합니다. 각 게시물에 대해.

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를 활성화할 수 있습니다. 다른 접근 방식을 사용하는 다른 환경에서.

개발 환경에서 다음 구성을 활성화할 수 있습니다.

Bullet.alert         = true

그러면 브라우저에 다음과 같은 경고가 표시됩니다.

Bullet.add_footer    = true

오류가 있는 페이지에 바닥글을 추가합니다.

브라우저 콘솔에 오류가 기록되도록 설정할 수도 있습니다.

Bullet.console    = true

다음과 같은 오류가 추가됩니다.

Appsignal과 함께 스테이징에서 Bullet 사용

스테이징에서 환경에서는 이러한 오류 메시지가 최종 사용자에게 표시되는 것을 원하지 않지만 응용 프로그램에 앞에서 언급한 문제 중 하나가 발생하기 시작하는지 아는 것이 좋습니다.

동시에 bullet 애플리케이션의 성능을 저하시키고 메모리 소비를 증가시킬 수 있으므로 스테이징에서 일시적으로만 활성화하는 것이 좋습니다. 하지만 프로덕션에서는 활성화하지 마세요. .

스테이징 가정 환경이 프로덕션과 동일한 구성 파일을 사용하고 있습니다. 둘 사이의 차이를 줄이는 좋은 방법인 환경에서 환경 변수를 사용하여 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에 의해 문제가 감지되면 , 다음과 같은 오류 인시던트를 생성합니다.

이 오류는 bullet에서 추출된 uniform_notifier gem에 의해 발생합니다. .

유감스럽게도 오류 메시지에는 충분한 정보가 표시되지 않지만 이를 개선하기 위해 Pull Request를 보냈습니다!

결론

bullet gem은 애플리케이션의 성능을 저하시키는 문제를 감지하는 데 도움이 되는 훌륭한 도구입니다.

앞서 언급한 대로 테스트 범위를 잘 유지하여 프로덕션으로 이동하기 전에 이러한 문제를 감지할 가능성을 높이십시오.

추가 팁으로 데이터베이스와 관련된 성능 문제로부터 더욱 보호받고 싶다면 적절한 인덱스를 사용하지 않는 쿼리를 감지하는 데 도움이 되는 wt-activerecord-index-spy gem을 살펴보십시오.

추신 Ruby Magic 게시물이 언론에 공개되는 즉시 읽고 싶다면 Ruby Magic 뉴스레터를 구독하고 게시물을 놓치지 마세요!