# ActiveRecord: แก้ปัญหาคิวรี N+1 ใน Ruby on Rails > คู่มือฉบับสมบูรณ์ในการตรวจจับและแก้ไขคิวรี N+1 ใน Rails ด้วย ActiveRecord เชี่ยวชาญ includes, preload, eager_load และเครื่องมือตรวจจับอัตโนมัติ - Published: 2026-02-23 - Updated: 2026-04-29 - Author: Anthony Fillion-Maillet - Tags: ruby on rails, activerecord, performance, n+1 queries, sql optimization - Reading time: 10 min --- คิวรี N+1 ถือเป็นหนึ่งในปัญหาด้านประสิทธิภาพที่พบบ่อยที่สุดในแอปพลิเคชัน Rails ลูปอย่างง่ายเหนือเรกคอร์ดสามารถสร้างคิวรี SQL ที่ไม่จำเป็นได้นับร้อย ทำให้เวลาตอบสนองช้าลงอย่างมาก คู่มือนี้ครอบคลุมเทคนิคในการตรวจจับและแก้ไข เพื่อให้ได้แอปพลิเคชัน Rails ที่มีประสิทธิภาพสูง > **ผลกระทบในโปรดักชัน** > > หน้าที่แสดงบทความ 50 รายการพร้อมผู้เขียนสามารถสร้างคิวรี SQL ได้ 51 ครั้งแทนที่จะเป็นเพียงคิวรีเดียว ในโปรดักชันที่มีผู้ใช้หลายพันคน ปัญหานี้กลายเป็นเรื่องสำคัญสำหรับเวลาตอบสนองและภาระของเซิร์ฟเวอร์ ## ทำความเข้าใจกับปัญหา N+1 ปัญหา N+1 เกิดขึ้นเมื่อโค้ดประมวลผลคิวรีหนึ่งครั้งเพื่อดึงรายการเรกคอร์ด (1 คิวรี) แล้วจึงเรียกคิวรีเพิ่มเติมสำหรับแต่ละเรกคอร์ดเพื่อเข้าถึงความสัมพันธ์ของมัน (N คิวรี) ชื่อ "N+1" อธิบายรูปแบบนี้ได้อย่างแม่นยำ: 1 คิวรีเริ่มต้น + N คิวรีสำหรับความสัมพันธ์ ตัวอย่างที่ชัดเจนกับบทความและผู้เขียนแสดงปัญหานี้ได้ดี โดยไม่มีการเพิ่มประสิทธิภาพ การเข้าถึงผู้เขียนของบทความแต่ละครั้งจะกระตุ้นให้เกิดคิวรี SQL ใหม่ ```ruby # app/controllers/articles_controller.rb # Example code generating an N+1 problem class ArticlesController < ApplicationController def index # 1 query: SELECT * FROM articles @articles = Article.all end end ``` ในวิว การเรียก `article.author` แต่ละครั้งจะกระตุ้นให้เกิดคิวรีเพิ่มเติมไปยังฐานข้อมูล ```erb <% @articles.each do |article| %>

<%= article.title %>

By <%= article.author.name %>

<% end %> ``` สำหรับบทความ 100 รายการ โค้ดนี้สร้างคิวรี SQL ได้ 101 ครั้ง บันทึกของ Rails แสดงปัญหาอย่างชัดเจนผ่านคิวรีที่ซ้ำกัน ```sql -- Rails logs showing the N+1 problem -- 1 initial query SELECT "articles".* FROM "articles" -- N queries for authors (repeated for each article) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1 SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1 -- ... 97 more queries ``` ## แก้ปัญหาด้วย includes เมธอด `includes` คือทางออกที่พบบ่อยและแนะนำที่สุดในการแก้ปัญหา N+1 โดยจะสั่งให้ ActiveRecord โหลดความสัมพันธ์ล่วงหน้าในคิวรีเดียวหรือสองคิวรีที่ปรับให้เหมาะสม ```ruby # app/controllers/articles_controller.rb # Solution with includes - preloading authors class ArticlesController < ApplicationController def index # Preloads authors with articles # Generates only 2 queries instead of N+1 @articles = Article.includes(:author).all end end ``` เมื่อใช้ `includes` ActiveRecord จะดำเนินการเพียงสองคิวรีไม่ว่าจะมีบทความจำนวนเท่าใด คิวรีแรกดึงบทความทั้งหมด คิวรีที่สองดึงผู้เขียนที่เกี่ยวข้องทั้งหมด ```sql -- Rails logs with includes (only 2 queries) SELECT "articles".* FROM "articles" SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...) ``` ความสัมพันธ์แบบซ้อนสามารถโหลดล่วงหน้าได้ด้วยไวยากรณ์แฮช วิธีนี้จำเป็นเมื่อวิวเข้าถึงความสัมพันธ์หลายระดับ ```ruby # app/controllers/articles_controller.rb # Preloading nested associations class ArticlesController < ApplicationController def index # Preloads author -> company and all comments @articles = Article.includes(author: :company, comments: :user) end end ``` > **กฎทอง** > > หากวิวเข้าถึงความสัมพันธ์ภายในลูป ความสัมพันธ์นั้นต้องถูกโหลดล่วงหน้าใน controller ด้วย `includes` ควรตรวจสอบรูปแบบการเข้าถึงความสัมพันธ์ในวิวอยู่เสมอ ## ความแตกต่างระหว่าง includes, preload และ eager_load Rails มีเมธอดสามแบบสำหรับการโหลดความสัมพันธ์ล่วงหน้า แต่ละแบบใช้กลยุทธ์ SQL ที่แตกต่างกัน พร้อมกรณีการใช้งานเฉพาะ ### preload: คิวรีแยก เมธอด `preload` จะดำเนินการคิวรีแยกสำหรับแต่ละความสัมพันธ์เสมอ ทำงานได้อย่างมีประสิทธิภาพเมื่อไม่มีเงื่อนไข WHERE กรองบนความสัมพันธ์ ```ruby # app/models/article.rb # preload always uses separate queries class Article < ApplicationRecord scope :with_authors, -> { preload(:author) } end # Usage in controller @articles = Article.with_authors.limit(20) # SQL generated: # SELECT "articles".* FROM "articles" LIMIT 20 # SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...) ``` ### eager_load: LEFT OUTER JOIN เมธอด `eager_load` ใช้ LEFT OUTER JOIN เพื่อโหลดข้อมูลในคิวรีเดียว วิธีนี้จำเป็นเมื่อต้องการกรองหรือเรียงลำดับตามคอลัมน์ของความสัมพันธ์ ```ruby # app/controllers/articles_controller.rb # eager_load allows filtering on associations class ArticlesController < ApplicationController def verified_authors # Filters articles by author status # Requires eager_load because WHERE references users @articles = Article.eager_load(:author) .where(users: { verified: true }) .order("users.name ASC") end end # SQL generated (single query with JOIN): # SELECT "articles"."id", "articles"."title", ... # FROM "articles" # LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id" # WHERE "users"."verified" = TRUE # ORDER BY "users"."name" ASC ``` ### includes: พฤติกรรมอัจฉริยะ เมธอด `includes` เลือกกลยุทธ์ที่ดีที่สุดโดยอัตโนมัติ ใช้ `preload` เป็นค่าเริ่มต้น แต่จะเปลี่ยนเป็น `eager_load` หากเงื่อนไข WHERE อ้างถึงความสัมพันธ์ ```ruby # app/controllers/articles_controller.rb # includes adapts automatically to context class ArticlesController < ApplicationController def index # No condition on association: uses preload (2 queries) @articles = Article.includes(:author).all end def by_verified_authors # With condition on association: uses eager_load (JOIN) @articles = Article.includes(:author) .where(users: { verified: true }) end end ``` ตารางต่อไปนี้สรุปความแตกต่างของทั้งสามเมธอด | เมธอด | กลยุทธ์ SQL | กรณีใช้งาน | |-------|-------------|-----------| | `preload` | คิวรีแยก | โหลดล่วงหน้าแบบง่าย ไม่มีตัวกรอง | | `eager_load` | LEFT OUTER JOIN | กรอง/เรียงลำดับบนความสัมพันธ์ | | `includes` | อัตโนมัติ | การใช้งานทั่วไป ค่าเริ่มต้นที่แนะนำ | ## การตรวจจับ N+1 อัตโนมัติ การตรวจจับปัญหา N+1 ด้วยมือนั้นน่าเบื่อและเกิดข้อผิดพลาดได้ง่าย เครื่องมือหลายตัวช่วยทำให้กระบวนการนี้เป็นอัตโนมัติทั้งในระยะพัฒนาและ CI ### Bullet: ตรวจจับแบบเรียลไทม์ เจม Bullet วิเคราะห์คิวรี SQL แบบเรียลไทม์และแจ้งเตือนเมื่อพบปัญหา N+1 พร้อมเสนอแนวทางแก้ไขที่เหมาะสม ```ruby # Gemfile # Bullet detects N+1 in development group :development do gem 'bullet' end ``` การกำหนดค่าในสภาพแวดล้อมการพัฒนาช่วยให้สามารถเปิดใช้งานโหมดแจ้งเตือนต่าง ๆ ได้ ```ruby # config/environments/development.rb # Bullet configuration to detect N+1 Rails.application.configure do config.after_initialize do Bullet.enable = true # Display JavaScript alert in browser Bullet.alert = true # Add footer with details Bullet.bullet_logger = true # Display in Rails logs Bullet.rails_logger = true # Raise exception (useful in CI) Bullet.raise = false end end ``` เมื่อพบปัญหา N+1 Bullet จะแสดงข้อความที่ชัดเจนพร้อมวิธีแก้ไขที่แนะนำ ```text # Example Bullet alert in logs USE eager loading detected Article => [:author] Add to your query: .includes([:author]) Call stack: /app/views/articles/index.html.erb:5 ``` > **Bullet ใน CI** > > ในการอินทิเกรชันต่อเนื่อง การเปิดใช้งาน `Bullet.raise = true` ทำให้การทดสอบล้มเหลวเมื่อพบปัญหา N+1 ซึ่งช่วยป้องกันการถดถอยด้านประสิทธิภาพ ### Prosopite: ทางเลือกที่เบากว่า เจม Prosopite เป็นทางเลือกที่เบากว่า Bullet โดยมีการกำหนดค่าน้อยและใช้งานร่วมกับการทดสอบได้ ```ruby # Gemfile # Prosopite as an alternative to Bullet group :development, :test do gem 'prosopite' end ``` ```ruby # config/environments/development.rb # Prosopite configuration Rails.application.configure do config.after_initialize do Prosopite.rails_logger = true Prosopite.raise = Rails.env.test? end end ``` ## เทคนิคการเพิ่มประสิทธิภาพขั้นสูง นอกเหนือจากเมธอดพื้นฐาน ยังมีอีกหลายเทคนิคที่ช่วยปรับการเพิ่มประสิทธิภาพคิวรี ActiveRecord ให้ละเอียดยิ่งขึ้น ### Strict Loading: ป้องกันโดยค่าเริ่มต้น Rails 6.1+ มีโหมดโหลดเข้มงวด ที่จะโยนข้อยกเว้นเมื่อมีการเข้าถึงความสัมพันธ์ที่ไม่ได้โหลดล่วงหน้า แนวทางป้องกันนี้บังคับให้แก้ปัญหา N+1 ตั้งแต่ในระยะพัฒนา ```ruby # app/models/article.rb # Enable strict loading by default on the model class Article < ApplicationRecord # Any non-preloaded association access raises an exception self.strict_loading_by_default = true belongs_to :author has_many :comments end ``` การโหลดเข้มงวดยังสามารถเปิดใช้งานสำหรับคิวรีแบบเฉพาะเจาะจงได้ ```ruby # app/controllers/articles_controller.rb # Strict loading on a specific query class ArticlesController < ApplicationController def index # Raises StrictLoadingViolationError if a non-included # association is accessed @articles = Article.strict_loading.includes(:author) end end ``` ### Select และ Pluck สำหรับข้อมูลบางส่วน เมื่อจำเป็นต้องใช้เพียงบางคอลัมน์ `select` และ `pluck` จะลดปริมาณข้อมูลที่ถ่ายโอนจากฐานข้อมูล ```ruby # app/controllers/reports_controller.rb # Optimization with select and pluck class ReportsController < ApplicationController def titles_only # select returns Article objects with only id and title @articles = Article.select(:id, :title) end def title_array # pluck returns an Array of values, not AR objects # More performant when only values are needed @titles = Article.pluck(:title) # => ["First article", "Second article", ...] end end ``` ### Counter Cache สำหรับการนับ การนับความสัมพันธ์ (`article.comments.count`) สร้างคิวรี SQL ในแต่ละครั้งที่เรียก Counter cache เก็บการนับนี้ไว้โดยตรงในตารางหลัก ```ruby # app/models/comment.rb # Counter cache configuration class Comment < ApplicationRecord # Rails automatically maintains the counter in articles.comments_count belongs_to :article, counter_cache: true end ``` การ migrate เพิ่มคอลัมน์การนับพร้อมค่าเริ่มต้น ```ruby # db/migrate/20260223_add_comments_count_to_articles.rb # Migration to add counter cache class AddCommentsCountToArticles < ActiveRecord::Migration[7.1] def change add_column :articles, :comments_count, :integer, default: 0, null: false # Initialize counters for existing data Article.find_each do |article| Article.reset_counters(article.id, :comments) end end end ``` หลังจากการกำหนดค่านี้ `article.comments_count` อ่านคอลัมน์โดยตรงโดยไม่ต้องคิวรี SQL เพิ่มเติม ```ruby # app/views/articles/index.html.erb # Using counter cache (no SQL query) <% @articles.each do |article| %>

<%= article.title %> - <%= article.comments_count %> comments

<% end %> ``` ## แนวปฏิบัติที่ดีที่สุดและเช็กลิสต์ แนวทางอย่างเป็นระบบช่วยป้องกันปัญหา N+1 ในการพัฒนาใหม่และค่อย ๆ แก้ไขโค้ดที่มีอยู่ ### วิเคราะห์วิวก่อนเขียนโค้ด ก่อนเขียนโค้ด controller ควรวิเคราะห์วิวเพื่อระบุความสัมพันธ์ทั้งหมดที่ถูกเข้าถึง การคาดการณ์ล่วงหน้านี้ช่วยลดการตกหล่น ```ruby # app/controllers/articles_controller.rb # Pre-analyze view to identify required includes class ArticlesController < ApplicationController def show # View accesses: author, author.company, comments, comments.user # All these must be preloaded @article = Article.includes( author: :company, comments: :user ).find(params[:id]) end end ``` ### Scope ที่ใช้ซ้ำได้ การรวบรวม includes ที่ใช้บ่อยไว้ใน scope ช่วยให้การบำรุงรักษาง่ายขึ้นและรับประกันความสม่ำเสมอ ```ruby # app/models/article.rb # Reusable scopes for preloading class Article < ApplicationRecord # Scope for list display scope :with_author, -> { includes(:author) } # Scope for detailed display scope :with_full_details, -> { includes( author: :company, comments: { user: :avatar_attachment }, tags: [] ) } # Scope for admin with all relations scope :for_admin, -> { includes(:author, :comments, :tags, :category) .with_attached_cover_image } end ``` ### เช็กลิสต์ป้องกัน เช็กลิสต์นี้สรุปจุดตรวจสอบสำคัญเพื่อหลีกเลี่ยงปัญหา N+1: - ติดตั้งและตั้งค่า Bullet หรือ Prosopite ในการพัฒนา - เปิดใช้งาน Bullet.raise ใน CI เพื่อบล็อกการถดถอย - วิเคราะห์วิวเพื่อระบุความสัมพันธ์ก่อนเขียน controller - ใช้ `includes` เป็นค่าเริ่มต้น `eager_load` หากกรองบนความสัมพันธ์ - สร้าง scope ที่ใช้ซ้ำได้สำหรับรูปแบบโหลดล่วงหน้าที่พบบ่อย - ใช้ strict loading กับโมเดลที่มีความสำคัญ - เพิ่ม counter cache สำหรับการนับที่เกิดบ่อย - ตรวจสอบบันทึก SQL เป็นประจำในระยะพัฒนา > **ระวังการโหลดล่วงหน้ามากเกินไป** > > การโหลดความสัมพันธ์มากเกินความจำเป็นทำให้สิ้นเปลืองหน่วยความจำโดยไม่จำเป็น ควรโหลดล่วงหน้าเฉพาะความสัมพันธ์ที่วิวใช้งานจริง เครื่องมืออย่าง Bullet ก็สามารถตรวจจับ "unused eager loading" ได้เช่นกัน ## บทสรุป คิวรี N+1 เป็นปัญหาด้านประสิทธิภาพที่สำคัญแต่สามารถป้องกันได้ง่ายในแอปพลิเคชัน Rails การผสมผสานระหว่างเครื่องมือตรวจจับอัตโนมัติและแนวปฏิบัติการพัฒนาที่ดีจะช่วยกำจัดปัญหานี้ได้อย่างมีประสิทธิภาพ **ประเด็นสำคัญ:** - `includes` แก้ไขกรณี N+1 ส่วนใหญ่ด้วยการโหลดความสัมพันธ์ล่วงหน้า - `eager_load` จำเป็นเมื่อกรองหรือเรียงลำดับบนความสัมพันธ์ - Bullet และ Prosopite ตรวจจับปัญหาในการพัฒนาโดยอัตโนมัติ - Strict loading ป้องกัน N+1 ด้วยการโยนข้อยกเว้น - Counter cache ช่วยเพิ่มประสิทธิภาพการนับที่เกิดบ่อย - การวิเคราะห์วิวก่อนเขียน controller ช่วยลดการตกหล่น --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/ruby-on-rails/activerecord-fixing-n-plus-1-query-problems