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

ActiveRecord 성능:N+1 쿼리 안티패턴

AppSignal에서 우리는 개발자의 애플리케이션 성능을 돕습니다. 수십억 개의 요청을 보내는 수많은 앱을 모니터링하고 있습니다. 우리는 Ruby와 성능에 대한 몇 가지 블로그 포스트를 통해서도 약간의 도움이 될 수 있다고 생각했습니다. N+1 쿼리 문제는 Rails 애플리케이션의 일반적인 반패턴입니다.

Rails의 ActiveRecord와 같은 많은 ORM에는 필요한 순간까지 쿼리 연결을 연기할 수 있도록 지연 로딩이 내장되어 있습니다. 이 결정을 보기에 오프로드하여 로드해야 하는 연결에 대해 암시적으로 허용합니다.

N+1 쿼리 문제는 일반적이지만 일반적으로 발견하기 쉬운 성능 반패턴으로 각 연결에 대해 쿼리를 실행하여 데이터베이스에서 많은 수의 연결을 쿼리할 때 오버헤드가 발생합니다.

<블록 인용>

👋 그런데 이 기사가 마음에 드시면 Ruby(on Rails) 성능에 대해 더 많이 썼습니다. Ruby 성능 모니터링 체크리스트를 확인하세요.

ActiveRecord의 지연 로딩

ActiveRecord는 관계 작업을 더 쉽게 하기 위해 암시적 지연 로딩을 사용합니다. 각 제품변형을 원하는 수만큼 가질 수 있습니다. 예를 들어 제품의 색상이나 크기를 포함합니다.

# app/models/product.rb
class Product < ActiveRecord::Base
  has_many :variants
end

ProductsController#show에서 , 제품 중 하나에 대한 세부 정보 보기에서는 Product.find(params[:id])를 사용합니다. 제품을 가져와 @product에 할당 변수.

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])
  end
end

이 작업에 대한 보기에서 variants를 호출하여 제품의 변형을 반복합니다. @product의 메소드 컨트롤러에서 받은 변수입니다.

# app/views/products/show.html.erb
<h1><%= @product.title %></h1>
 
<ul>
<%= @product.variants.each do |variant| %>
  <li><%= variant.name %></li>
<% end %>
</ul>

@product.variants를 호출하여 보기에서 Rails는 우리가 반복할 수 있는 변형을 얻기 위해 데이터베이스를 쿼리할 것입니다. 컨트롤러에서 수행한 명시적 쿼리 외에도 이 요청에 대한 Rails의 로그를 확인하면 변형을 가져오기 위해 다른 쿼리가 실행되는 것을 볼 수 있습니다.

Started GET "/products/1" for 127.0.0.1 at 2018-04-19 08:49:13 +0200
Processing by ProductsController#show as HTML
  Parameters: {"id"=>"1"}
  Product Load (1.1ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Rendering products/show.html.erb within layouts/application
  Variant Load (1.1ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 1]]
  Rendered products/show.html.erb within layouts/application (4.4ms)
Completed 200 OK in 64ms (Views: 56.4ms | ActiveRecord: 2.3ms)

이 요청은 모든 변형이 있는 제품을 표시하기 위해 두 개의 쿼리를 실행했습니다.

  1. SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1
  2. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1

루프 지연 로딩

지연 로딩은 지금까지 훌륭했습니다. 암시적 쿼리를 사용하면 예를 들어 이 보기에 변형을 더 이상 표시하지 않기로 결정할 때 컨트롤러에서 쿼리를 제거하는 것을 기억할 필요가 없습니다.

ProductsController#index에서 작업 중이라고 가정해 보겠습니다. , 여기에서 각 변형이 있는 모든 제품 목록을 표시하고 싶습니다. 이전과 같은 방식으로 지연 로딩으로 이를 구현할 수 있습니다.

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all
  end
end
# app/views/products/index.html.erb
<h1>Products</h1>
 
<% @products.each do |product| %>
<article>
  <h1><%= product.title %></h1>
 
  <ul>
    <% product.variants.each do |variant| %>
      <li><%= variant.description %></li>
    <% end %>
  </ul>
</article>
<% end %>

첫 번째 예와 달리 이제 컨트롤러에서 단일 제품 목록이 아닌 제품 목록을 얻습니다. 그런 다음 보기는 각 제품을 반복하고 지연 로드는 각 제품에 대한 각 변형을 로드합니다.

이것이 작동하는 동안 한 가지 캐치가 있습니다. 이제 쿼리 수가 N+1입니다. .

N+1 쿼리

첫 번째 예에서는 단일 제품 및 해당 변형에 대한 보기를 렌더링했습니다. 쿼리 수 두 개의 쿼리를 실행했기 때문에 2였습니다. 이 요청은 데이터베이스의 모든 제품(이 예에서는 3)과 각 변형을 반환했으며 쿼리는 2개가 아닌 4개였습니다.

Started GET "/products" for 127.0.0.1 at 2018-04-19 09:49:02 +0200
Processing by ProductsController#index as HTML
  Rendering products/index.html.erb within layouts/application
  Product Load (0.3ms)  SELECT "products".* FROM "products"
  Variant Load (0.2ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 1]]
  Variant Load (0.2ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 2]]
  Variant Load (0.1ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ?  [["product_id", 3]]
  Rendered products/index.html.erb within layouts/application (5.6ms)
Completed 200 OK in 36ms (Views: 32.6ms | ActiveRecord: 0.8ms)
  1. SELECT "products".* FROM "products"
  2. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1
  3. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 2
  4. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 3

Product.all에 대한 명시적 호출에 의해 실행되는 첫 번째 쿼리 컨트롤러에서 모든 제품을 찾습니다. 후속 작업은 보기의 각 제품을 반복하면서 느리게 실행됩니다.

이 예에서는 쿼리 수가 N+1이 됩니다. 여기서 N은 제품 수이고 추가된 것은 모든 제품을 가져온 명시적 쿼리입니다. 다시 말해; 이 예제는 하나의 쿼리를 수행한 다음 첫 번째 쿼리의 각 결과에 대해 다른 쿼리를 수행합니다. 이 예에서 N =3이므로 결과 쿼리 수는 N + 1 = 3 + 1 = 4입니다. .

제품이 세 개뿐인 경우에는 실제로 문제가 되지 않을 수 있지만 쿼리 수는 제품 수와 함께 증가합니다. 이 요청에 N+1개의 쿼리가 있다는 것을 알고 있기 때문에 100개의 제품이 있을 때 101개의 쿼리 수를 예측할 수 있습니다(N + 1 = 100 + 1 = 101 ), 예를 들어

빠른 로딩 연결

지금처럼 제품 수로 쿼리 수를 늘리는 대신 이 보기에서 정적 요청 수를 원합니다. 뷰를 렌더링하기 전에 컨트롤러에서 변형을 명시적으로 미리 로드하여 이를 수행할 수 있습니다.

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all.includes(:variants)
  end
end

ActiveRecord의 includes 쿼리 메서드는 연결된 변형이 해당 제품과 함께 로드되는지 확인합니다. 어떤 변형이 미리 로드되어야 하는지 알고 있기 때문에 하나의 쿼리에서 요청된 모든 제품의 모든 변형을 가져올 수 있습니다.

Started GET "/products" for 127.0.0.1 at 2018-04-19 10:33:59 +0200
Processing by ProductsController#index as HTML
  Rendering products/index.html.erb within layouts/application
  Product Load (0.3ms)  SELECT "products".* FROM "products"
  Variant Load (0.4ms)  SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (?, ?, ?)  [["product_id", 1], ["product_id", 2], ["product_id", 3]]
  Rendered products/index.html.erb within layouts/application (5.9ms)
  Completed 200 OK in 45ms (Views: 40.8ms | ActiveRecord: 0.7ms)

변형을 미리 로드하면 향후 제품 수가 증가하더라도 쿼리 수가 다시 2로 줄어듭니다.

  1. SELECT "products".* FROM "products"
  2. SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (1, 2, 3)

게으르거나 열심입니까?

대부분의 경우 단일 쿼리로 데이터베이스에서 모든 관련 레코드를 가져오는 것이 지연 로드보다 훨씬 빠릅니다.

이 예제 애플리케이션에서 데이터베이스 성능 차이는 각각 10개의 변형이 있는 3개의 제품으로 측정할 수 있습니다. 평균적으로 제품 목록을 즉시 로드하는 것은 지연 로드보다 약 12.5% ​​더 빠릅니다(0.7ms 대 0.8ms). 10개 제품의 경우 그 차이는 59%로 뛰어납니다(1.22ms 대 2.98ms). 1000개 제품의 경우, 열성 쿼리가 58.4ms로 클럭을 시작하는 반면 지연 로드는 약 290.12ms가 걸리므로 그 차이는 거의 80%입니다.

지연 로드 연결은 컨트롤러를 업데이트하지 않고도 보기에서 더 많은 유연성을 제공하지만 좋은 경험 법칙은 데이터를 보기로 전달하기 전에 컨트롤러가 데이터 로드를 처리하도록 하는 것입니다.

보기에서 지연 로드는 하나의 모델 개체와 해당 연결(예:ProductsController#show)을 표시하는 보기에서 작동합니다. 첫 번째 예에서) 예를 들어 동일한 컨트롤러에서 다른 데이터가 필요한 여러 보기가 있을 때 유용할 수 있습니다.

고양이와 인형

고양이는 동의하지 않을 수 있지만 때로는 게으른 것보다 열심인 것이 좋습니다. 이 게시물에서 우리는 ActiveRecord의 지연 로딩에 대해 살펴보고 이것이 성능 문제를 일으킬 수 있는 상황의 예를 보여주었습니다. N+1 쿼리 문제로 이어질 때와 같습니다.

간단히 말해서:개발 로그 또는 AppSignal의 이벤트 타임라인을 항상 주시하여 지연 로드될 수 있는 쿼리를 수행하지 않도록 하고 특히 처리되는 데이터 양이 증가할 때 응답 시간을 추적하십시오. .

이 내용이 마음에 드시면 Russian Doll Caching에 대한 이 즐겨찾기 또는 Conditional Get Requests에 대한 것과 같이 성능 및 모니터링에 대해 작성한 몇 가지 추가 사항을 확인하십시오.