ที่ 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 รายการเพื่อแสดงผลิตภัณฑ์พร้อมรายละเอียดปลีกย่อยทั้งหมด
SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1
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)
SELECT "products".* FROM "products"
SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1
SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 2
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 แม้ว่าจำนวนสินค้าจะเพิ่มขึ้นในอนาคตก็ตาม
SELECT "products".* FROM "products"
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