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