ที่ AppSignal เราช่วยนักพัฒนาด้านประสิทธิภาพแอปพลิเคชัน เรากำลังติดตามแอปจำนวนมากที่ส่งคำขอหลายพันล้านครั้ง เราคิดว่าเราสามารถช่วยได้อีกด้วยบล็อกโพสต์เกี่ยวกับ Ruby และประสิทธิภาพ ปัญหา N+1 queries เป็นแอนติแพทเทิร์นที่พบบ่อยในแอปพลิเคชัน Rails
ORM หลายตัว เช่น ActiveRecord ของ Rails มีการโหลดแบบขี้เกียจ (lazy loading) ในตัว เพื่อให้คุณสามารถเลื่อนการสอบถาม associations จนกว่าจะต้องการจริง มันช่วยให้คุณสามารถโดยนัยได้ว่า associations ไหนที่ต้องโหลดโดยการโยนการตัดสินใจนี้ให้กับ view
ปัญหา N+1 queries เป็นแอนติแพทเทิร์นด้านประสิทธิภาพที่พบบ่อยและมักค่อนข้างง่ายที่จะตรวจพบ ซึ่งทำให้เกิดการรันคิวรีสำหรับแต่ละ association สร้าง overhead เมื่อสอบถาม associations จำนวนมากจากฐานข้อมูล
👋 ไว้ใจนะ หากคุณชอบบทความนี้ เรามีเนื้อหาเกี่ยวกับประสิทธิภาพ Ruby (on Rails) มากมาย ดู checklist การติดตามประสิทธิภาพ Ruby ของเราได้เลย
Lazy Loading ใน ActiveRecord
ActiveRecord ใช้ implicit lazy loading เพื่อให้การทำงานกับ relations ง่ายขึ้น มาพิจารณาตัวอย่างร้านค้าออนไลน์ ซึ่งแต่ละ Product สามารถมี 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
ใน view สำหรับ action นี้ เราจะวนลูปผ่าน variants ของสินค้าด้วยการเรียกเมธอด variants บนตัวแปร @product ที่ได้จาก controller
# app/views/products/show.html.erb
<h1><%= @product.title %></h1>
<ul>
<%= @product.variants.each do |variant| %>
<li><%= variant.name %></li>
<% end %>
</ul>
เมื่อเรียก @product.variants ใน view Rails จะสอบถามฐานข้อมูลเพื่อดึง variants ให้เราวนลูป นอกจากคิวรีที่ระบุชัดเจนใน controller แล้ว เราจะเห็นอีกคิวรีหนึ่งที่รันเพื่อดึง variants หากตรวจสอบ log ของ Rails สำหรับ request นี้
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)
Request นี้รันคิวรี 2 ครั้งเพื่อแสดงสินค้าพร้อม variants ทั้งหมด
SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1
Lazy Loading แบบวนลูป
Lazy loading ดีจนถึงตอนนี้ โดยใช้ implicit query เราไม่ต้องจำว่าต้องลบออกจาก controller เมื่อตัดสินใจไม่แสดง variants ใน view นั้นอีกต่อไป
สมมติเรากำลังทำ ProductsController#index ซึ่งต้องการแสดงรายการสินค้าทั้งหมดพร้อม variants ของแต่ละตัว เราสามารถทำได้ด้วย 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 %>
ต่างจากตัวอย่างแรก ตอนนี้เราได้รายการสินค้าจาก controller แทนที่จะเป็นสินค้าเพียงชิ้นเดียว View จึงวนลูปแต่ละสินค้าและ lazy load variants สำหรับแต่ละสินค้า
แม้จะทำงานได้ แต่มีจุดสังเกตหนึ่ง จำนวนคิวรีกลายเป็น N+1
ปัญหา N+1 Queries
ในตัวอย่างแรก เรา render view สำหรับสินค้าเดียวพร้อม variants จำนวน query count คือ 2 เพราะรัน 2 คิวรี Request นี้คืนสินค้าทั้งหมด (3 ชิ้นในตัวอย่าง) พร้อม variants ของแต่ละชิ้น และรัน 4 คิวรีแทนที่จะเป็น 2
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" = 1SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 2SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 3
คิวรีแรก รันโดยการเรียก Product.all ใน controller ค้นหาสินค้าทั้งหมด คิวรีต่อๆ ไปรันแบบ lazy ขณะวนลูปแต่ละสินค้าใน view
ตัวอย่างนี้ทำให้ query count เป็น N+1 โดย N คือจำนวนสินค้า และบวก 1 คือคิวรีที่ดึงสินค้าทั้งหมด กล่าวคือ รัน 1 คิวรี แล้วตามด้วยอีก 1 คิวรีสำหรับแต่ละผลลัพธ์จากคิวรีแรก เนื่องจาก N = 3 จำนวนคิวรีรวมคือ N + 1 = 3 + 1 = 4
แม้จะไม่ใช่ปัญหาเมื่อมีแค่ 3 สินค้า จำนวนคิวรีจะเพิ่มขึ้นตามจำนวนสินค้า เรารู้ว่า request นี้มี N+1 queries จึงคาดการณ์ได้ว่าจะมี 101 คิวรีเมื่อมี 100 สินค้า (N + 1 = 100 + 1 = 101)
Eager Loading Associations
แทนที่จะเพิ่มจำนวนคิวรีตามจำนวนสินค้า เราต้องการจำนวนคิวรีคงที่ใน view ทำได้โดยการ preload variants ใน controller ก่อน render view
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
@products = Product.all.includes(:variants)
end
end
เมธอด includes ของ ActiveRecord ทำให้ variants โหลดพร้อมกับ products เนื่องจากรู้ล่วงหน้าว่าต้องโหลด variants ไหน จึงดึง variants ทั้งหมดของสินค้าที่ขอในคิวรีเดียว
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)
ด้วยการ preload variants จำนวนคิวรีลดเหลือ 2 แม้จำนวนสินค้าจะเพิ่มขึ้นในอนาคต
SELECT "products".* FROM "products"SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (1, 2, 3)
Lazy หรือ Eager?
ในส่วนใหญ่ การดึง associated records ทั้งหมดในคิวรีเดียวเร็วกว่า lazy loading มาก
ในแอปตัวอย่างนี้ ความแตกต่างของประสิทธิภาพฐานข้อมูลวัดได้แม้มีแค่ 3 สินค้า แต่ละตัวมี 10 variants ค่าเฉลี่ย eager loading เร็วกว่า lazy loading ประมาณ 12.5% (0.7 ms vs 0.8 ms) เมื่อมี 10 สินค้า ความแตกต่างกระโดดเป็น 59% (1.22 ms vs 2.98 ms) เมื่อมี 1000 สินค้า ความแตกต่างเกือบ 80% eager queries ใช้ 58.4 ms ในขณะ lazy loading ใช้ประมาณ 290.12 ms
แม้ lazy loading ให้ความยืดหยุ่นใน view โดยไม่ต้องอัปเดต controller แนวทางปฏิบัติที่ดีคือให้ controller จัดการโหลดข้อมูลก่อนส่งให้ view
Lazy loading จาก view ใช้ได้สำหรับ view ที่แสดง model object เดียวและ associations (เช่น ProductsController#show ในตัวอย่างแรก) และมีประโยชน์เมื่อมีหลาย view ที่ต้องการข้อมูลต่างกันจาก controller เดียวกัน
แมวและตุ๊กตา
แมวอาจไม่เห็นด้วย แต่บางครั้งการกระทำแบบ eager ดีกว่า lazy ในบทความนี้เราสำรวจ lazy loading ใน ActiveRecord และแสดงตัวอย่างสถานการณ์ที่สร้างปัญหาประสิทธิภาพ เช่น เมื่อนำไปสู่ปัญหา N+1 queries
สรุป: ควรติดตาม development logs หรือ event timeline ใน AppSignal เพื่อให้แน่ใจว่าไม่ได้รันคิวรีที่ควรจะ eager load และติดตาม response times โดยเฉพาะเมื่อปริมาณข้อมูลที่ประมวลผลเพิ่มขึ้น
หากชอบบทความนี้ ดูเพิ่มเติมเกี่ยวกับประสิทธิภาพและการติดตาม เช่น Russian Doll Caching หรือ Conditional Get Requests