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

ประสิทธิภาพของ ActiveRecord:N+1 แบบสอบถาม antipattern

ที่ AppSignal เราช่วยนักพัฒนาด้วยประสิทธิภาพของแอปพลิเคชัน เรากำลังตรวจสอบแอปจำนวนมากที่ส่งคำขอนับพันล้านรายการ เราคิดว่าเราสามารถช่วยบล็อกโพสต์เกี่ยวกับ Ruby และประสิทธิภาพได้เล็กน้อย ปัญหาการสืบค้น N+1 เป็นการต่อต้านรูปแบบทั่วไปในแอปพลิเคชัน Rails

ORM จำนวนมาก เช่น ActiveRecord ของ Rails มีการโหลดแบบ Lazy Loading ในตัวเพื่อให้คุณสามารถเลื่อนการเชื่อมโยงการสืบค้นได้จนกว่าจะถึงเวลาที่จำเป็น อนุญาตให้แสดงนัยว่าต้องโหลดการเชื่อมโยงใดโดยยกเลิกการโหลดการตัดสินใจนี้ไปยังมุมมอง

ปัญหาการสืบค้น N+1 เป็นปัญหาที่พบได้ทั่วไปแต่มักจะสังเกตได้ง่าย แอนตี้แพทเทิร์นประสิทธิภาพซึ่งส่งผลให้มีการเรียกใช้คิวรีสำหรับแต่ละการเชื่อมโยง ซึ่งทำให้เกิดโอเวอร์เฮดเมื่อทำการสืบค้นการเชื่อมโยงจำนวนมากจากฐานข้อมูล

👋 อย่างไรก็ตาม หากคุณชอบบทความนี้ ยังมีอีกมากที่เราเขียนเกี่ยวกับประสิทธิภาพของ Ruby (on Rails) โปรดดูรายการตรวจสอบประสิทธิภาพ Ruby ของเรา

ขี้เกียจโหลดใน ActiveRecord

ActiveRecord ใช้การโหลดแบบ Lazy Loading โดยนัยเพื่อให้ทำงานกับความสัมพันธ์ได้ง่ายขึ้น มาลองพิจารณาตัวอย่างเว็บช็อป โดยที่แต่ละ ผลิตภัณฑ์ สามารถมี Variants จำนวนเท่าใดก็ได้ ซึ่งประกอบด้วยสีหรือขนาดของสินค้า เป็นต้น

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

คำขอนี้ดำเนินการค้นหา 2 รายการเพื่อแสดงผลิตภัณฑ์พร้อมรายละเอียดปลีกย่อยทั้งหมด

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

โหลดช้าแบบวนซ้ำ

การโหลดแบบ Lazy นั้นยอดเยี่ยมมาก เมื่อใช้การสืบค้นข้อมูลโดยนัย เราไม่จำเป็นต้องลบออกจากตัวควบคุมเมื่อเราตัดสินใจว่าไม่ต้องการแสดงตัวแปรในมุมมองนี้อีกต่อไป เป็นต้น

สมมติว่าเรากำลังดำเนินการกับ ProductsController#index ที่เราต้องการแสดงรายการผลิตภัณฑ์ทั้งหมดที่มีรายละเอียดปลีกย่อยแต่ละรายการ เราสามารถดำเนินการได้ด้วยการโหลดแบบ Lazy Loading แบบเดียวกับที่เคยทำ

# 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 รายการ) จากฐานข้อมูลและตัวเลือกสินค้าแต่ละรายการ และทำการค้นหาสี่รายการแทนที่จะเป็นสองคำ

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 เราจึงสามารถคาดการณ์จำนวนการสืบค้น 101 เมื่อเรามีผลิตภัณฑ์ 100 รายการ (N + 1 = 100 + 1 = 101 ) เป็นต้น

เชื่อมโยงอย่างกระตือรือร้น

แทนที่จะเพิ่มจำนวนการสืบค้นด้วยจำนวนผลิตภัณฑ์เหมือนที่เราทำในตอนนี้ เราต้องการให้มีจำนวนคำขอคงที่ในมุมมองนี้ เราสามารถทำได้โดยโหลดตัวแปรล่วงหน้าในคอนโทรลเลอร์ไว้ล่วงหน้าก่อนที่จะแสดงผลมุมมอง

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

includes .ของ ActiveRecord วิธีการสืบค้นช่วยให้แน่ใจว่ารายละเอียดปลีกย่อยที่เกี่ยวข้องถูกโหลดพร้อมกับสินค้าของพวกเขา เนื่องจากรู้ว่าต้องโหลดรายละเอียดปลีกย่อยใดล่วงหน้า จึงสามารถดึงข้อมูลรายละเอียดปลีกย่อยทั้งหมดของผลิตภัณฑ์ที่ร้องขอทั้งหมดในการค้นหาเดียว

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 ตัวแปร โดยเฉลี่ย การโหลดรายการผลิตภัณฑ์อย่างกระตือรือร้นจะเร็วกว่าการโหลดแบบ Lazy Loading ประมาณ 12.5% ​​(0.7 ms เทียบกับ 0.8 ms) ด้วยผลิตภัณฑ์ 10 รายการ ความแตกต่างนั้นเพิ่มขึ้นเป็น 59% (1.22 ms เทียบกับ 2.98 ms) ด้วยผลิตภัณฑ์ 1,000 รายการ ความแตกต่างเกือบ 80% เนื่องจากข้อความค้นหาที่กระตือรือร้นมีนาฬิกาอยู่ที่ 58.4 ms ในขณะที่การโหลดแบบ Lazy Loading จะใช้เวลาประมาณ 290.12 ms

แม้ว่าการเชื่อมโยงที่โหลดอย่างเกียจคร้านจะให้ความยืดหยุ่นมากขึ้นในมุมมองโดยไม่ต้องอัปเดตคอนโทรลเลอร์ แต่หลักการทั่วไปที่ดีคือให้คอนโทรลเลอร์จัดการโหลดข้อมูลก่อนที่จะส่งต่อไปยังมุมมอง

การโหลดแบบขี้เกียจจากมุมมองใช้ได้กับมุมมองที่แสดงวัตถุรุ่นหนึ่งและเป็นการเชื่อมโยง (เช่น ProductsController#show ในตัวอย่างแรกของเรา) และอาจมีประโยชน์เมื่อมีมุมมองหลายมุมมองที่ต้องการข้อมูลที่แตกต่างจากตัวควบคุมเดียวกัน เป็นต้น

แมวและตุ๊กตา

แมวอาจไม่เห็นด้วย แต่บางครั้งมันก็คุ้มค่าที่จะกระตือรือร้นมากกว่าขี้เกียจ ในโพสต์นี้ เราได้เจาะลึกถึงการโหลดแบบสันหลังยาวใน ActiveRecord และแสดงตัวอย่างสถานการณ์ที่อาจทำให้เกิดปัญหาด้านประสิทธิภาพได้ เช่นเมื่อมันนำไปสู่ปัญหาการสืบค้น N+1

กล่าวโดยย่อ:คอยดูบันทึกการพัฒนาหรือไทม์ไลน์ของเหตุการณ์ใน AppSignal เสมอ เพื่อให้แน่ใจว่าคุณไม่ได้ทำการสืบค้นที่อาจโหลดได้แบบ Lazy Loading และติดตามเวลาตอบสนองของคุณ โดยเฉพาะอย่างยิ่งเมื่อปริมาณข้อมูลที่ประมวลผลเพิ่มขึ้น .

หากคุณชอบสิ่งนี้ ลองดูสิ่งที่เราเขียนเกี่ยวกับประสิทธิภาพและการตรวจสอบเพิ่มเติม เช่น รายการโปรดเกี่ยวกับ Russian Doll Caching หรือสิ่งนี้เกี่ยวกับ Conditional Get Requests