I'm a Ruby on Rails / jQuery web developer. Follow me at @sikachu

Optimize database query ด้วย :include

August 7th, 2009 Posted in My Project, Programming, Ruby, Ruby on Rails

ในการเขียนโปรแกรมบน Ruby on Rails นั้น เรามักที่จะใช้ ActiveRecord ในการทำหน้าที่เป็น ORM ระหว่างตัว Application กับ database ซื่งทำให้การเรียก Record นั้น สามารถทำได้อย่างง่ายดาย เช่น ถ้าผมจะเรียกดู post ทั้งหมดที่มีอยู่ในระบบ ผมแค่สั่ง

Post.find(:all)    # หรือว่า Post.all ก็ได้ ใน Rails 2.x

ซึ่งตรงนี้ ถ้าเราไปดูใน Log file จะพบว่า ActiveRecord นั้น จะใช้คำสั่งค้นหาข้อมูลประมาณนี้ครับ

  Post Load (0.1ms)   SELECT * FROM "posts"

(ผมใช่ sqlite3 เพราะฉะนั้น table name/field name จะถูกใส่ไว้ใน quote ครับ)

ถ้าสมมุติในโปรแกรมนั้น เราได้ทำ Association ระหว่าง Post และ Comment (Post has many comments) และระหว่าง Comment กับ User (comment belongs to user)

class Post < ActiveRecord::Base
  has_many :comments
 
  # ...
 
end
 
class Comment < ActiveRecord::Base
  belongs_to :post
  belongs_to :user
 
  # ...
 
end

ถ้าเราต้องการจะแสดงผล comment แต่ละอันด้วย เราก็สามารถทำได้โดยเรียกเมธอด #comments ที่ถูกสร้างขึ้นมาอัตโนมัติโดยการทำ association และเช่นเดียวกัน ถ้าเราต้องการแสดงด้วยว่า comment นั้นถูกเขียนโดยใคร เราก็สามารถเรียกเมธอด #user บน comment เช่นกัน

<% Post.find(:all).each do |post| %>
  <!-- display post -->
  <% post.comments.each do |comment| %>
    By: <%= comment.user.username %>
    <!-- display comments -->
  <% end %>
<% end %>

คราวนี้ สมมุติว่าบล็อกเรามีทั้งหมด 10 Post แล้วแต่ละอันมี 5 comment … SQL ที่ออกมานั้น จะเป็นประมาณนี้ครับ

  Post Load (0.1ms)   SELECT * FROM "posts"
  Comment Load (0.5ms)   SELECT * FROM "comments" WHERE ("comments".
"post_id" = '1')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '5')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '24')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '30')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '4')
  CACHE (0.0ms)   SELECT * FROM "users" WHERE ("user"."id" = '5')
  Comment Load (0.3ms)   SELECT * FROM "comments" WHERE ("comments".
"post_id" = '2')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '38')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '14')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '40')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '2')
  User Load (0.2ms)   SELECT * FROM "users" WHERE ("user"."id" = '9')
  ...

จะเห็นได้ว่า ในสถานการณ์ที่แย่ที่สุดนั้น (user ที่มา comment แต่ละ post นั้น ไม่ตรงกันเลย เป็นต้น) ActiveRecord จำเป็นต้องทำการ Query ทั้งหมด 1 + 10 + (10 * 5) = 61 ครั้ง ซึ่งไม่มีประสิทธิภาพเลยครับ เพราะเปลือง Query มากมาย

ดังนั้น เพื่อให้ Query ทั้งหมดนี่มีประสิทธิภาพมากขึ้น ActiveRecord จึงมี key หนึ่งชื่อว่า :include เอาไว้สำหรับสั่งว่าให้ ActiveRecord นั้นทำการโหลด Model ที่ associates กับ object นี้ขึ้นมาด้วยพร้อมๆ กันเลย เพื่อประหยัด Query ครับ เพราะฉะนั้นโค้ดในการค้นหาของเราจะเปลี่ยนเป็น

Post.find(:all, :include => {:comments => :user}).each do |post|
  # ... display post
  post.comments.each do |comment|
    By: <%= comment.user.username %>
    # ... display comments
  end
end

แล้วผลของมันน่ะหรอครับ? 61 query -> 3 queries ครับ!

  Post Load (0.1ms)   SELECT * FROM "posts"
  Comment Load (0.3ms)   SELECT * FROM "comments" WHERE ("comments".
"post_id" IN (1, 2, 3, 4, 5))
  User Load (1.3ms)   SELECT * FROM "users" WHERE ("user"."id" IN (5, 24,
30, 4, 2, 38, 14, 40, 2, 9, 23, 41, 48, 50, 32, 10, 48)

เพราะฉะนั้นการใช้ :include นั้น เป็นการ optimize query อย่างได้ผลทีเดียวละครับ โดยจะเห็นได้ว่าเรายังสามารถโหลด model แบบ nested ได้โดยการใช้ Hash และโหลดโมเดลหลายๆ อันพร้อมกันโดยใช้ Array ครับ อย่างเช่นถ้าเราต้องการโหลด Attachments จาก Comment และโหลด Tags จาก Post ด้วย เราก็สามารถใช้คำสั่งอย่างนี้ได้ครับ

Post.find(:all, :include => [{:comments => [:user, :attachments]}, 
:tags])

คำเตือน: Use it, but don’t abuse it!

ในบางครั้ง การใช้ :include นั้น อาจจะทำให้เวลาในการ query นั้นลดลงได้ถ้าเทียบกับการ query object เล็กๆ หลายๆ ครั้งแทน เพราะฉะนั้นมันไม่ใช่สิ่งที่เวิร์คที่สุดครับ ต้องปรับใช้ให้เข้ากับงานซะมากกว่า โดยที่ผมแนะนำให้ใช้ #find method ตามปกติก่อน แล้วจึงค่อยเพิ่ม :include เข้าไปถ้าเราเห็นว่ามีการ query record จำนวนมากๆครับ … ถือซะว่าการใช้ :include นั้นเป็นการ refactor code ครับ และไม่ใช่สิ่งที่ต้องมาคิดตั้งแต่แรกว่าตรงนี้ต้องใช้มันหรือไม่ ;)

  • ขอบคุณข้อมูลครับ กำลังหาข้อมูลพอดีเลย
blog comments powered by Disqus