# ActiveRecord: Mengatasi Masalah Query N+1 di Ruby on Rails
> Panduan lengkap untuk mendeteksi dan mengatasi query N+1 di Rails dengan ActiveRecord. Kuasai includes, preload, eager_load, dan alat deteksi otomatis.
- 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
---
Query N+1 merupakan salah satu masalah kinerja yang paling umum pada aplikasi Rails. Sebuah loop sederhana di atas record dapat memicu ratusan query SQL yang tidak perlu, yang secara drastis memperlambat waktu respons. Panduan ini membahas teknik deteksi dan penyelesaian untuk memastikan aplikasi Rails yang berkinerja baik.
> **Dampak di produksi**
>
> Sebuah halaman yang menampilkan 50 artikel beserta penulisnya dapat menghasilkan 51 query SQL alih-alih hanya satu. Di lingkungan produksi dengan ribuan pengguna, masalah ini menjadi kritis bagi waktu respons dan beban server.
## Memahami masalah N+1
Masalah N+1 terjadi ketika kode menjalankan satu query untuk mengambil daftar record (1 query), kemudian menjalankan query tambahan untuk setiap record guna mengakses asosiasinya (N query). Nama "N+1" menggambarkan persis pola ini: 1 query awal + N query untuk asosiasi.
Contoh konkret dengan artikel dan penulisnya menggambarkan masalah ini. Tanpa optimisasi, setiap akses ke penulis sebuah artikel akan memicu query SQL baru.
```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
```
Di view, setiap pemanggilan `article.author` memicu satu query tambahan ke basis data.
```erb
<% @articles.each do |article| %>
<%= article.title %>
By <%= article.author.name %>
<% end %>
```
Untuk 100 artikel, kode ini menghasilkan 101 query SQL. Log Rails dengan jelas menunjukkan masalah melalui query yang berulang.
```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
```
## Mengatasi dengan includes
Metode `includes` adalah solusi paling umum dan direkomendasikan untuk memperbaiki masalah N+1. Metode ini memberi tahu ActiveRecord untuk memuat asosiasi terlebih dahulu dalam satu atau dua query yang dioptimalkan.
```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
```
Dengan `includes`, ActiveRecord hanya menjalankan dua query terlepas dari jumlah artikel. Yang pertama mengambil semua artikel, yang kedua mengambil semua penulis terkait.
```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, ...)
```
Asosiasi bersarang juga dapat dimuat terlebih dahulu menggunakan sintaks hash. Pendekatan ini sangat penting saat view mengakses beberapa tingkat asosiasi.
```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
```
> **Aturan emas**
>
> Jika sebuah view mengakses asosiasi di dalam loop, asosiasi tersebut harus dimuat terlebih dahulu di controller dengan `includes`. Pola akses asosiasi pada view sebaiknya selalu diperiksa.
## Perbedaan antara includes, preload, dan eager_load
Rails menyediakan tiga metode untuk pemuatan asosiasi terlebih dahulu. Setiap metode menggunakan strategi SQL berbeda dengan kasus penggunaan tertentu.
### preload: query terpisah
Metode `preload` selalu menjalankan query terpisah untuk setiap asosiasi. Metode ini bekerja efisien saat tidak ada kondisi WHERE yang memfilter pada asosiasi.
```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
Metode `eager_load` menggunakan LEFT OUTER JOIN untuk memuat data dalam satu query tunggal. Metode ini menjadi wajib saat memfilter atau mengurutkan berdasarkan kolom asosiasi.
```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: perilaku cerdas
Metode `includes` secara otomatis memilih strategi terbaik. Secara default menggunakan `preload`, tetapi beralih ke `eager_load` jika klausa WHERE merujuk pada asosiasi.
```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
```
Tabel berikut merangkum perbedaan antara ketiga metode tersebut.
| Metode | Strategi SQL | Kasus Penggunaan |
|--------|--------------|------------------|
| `preload` | Query terpisah | Pemuatan sederhana, tanpa filter |
| `eager_load` | LEFT OUTER JOIN | Filter/pengurutan pada asosiasi |
| `includes` | Otomatis | Penggunaan umum, default yang direkomendasikan |
## Deteksi N+1 secara otomatis
Deteksi manual masalah N+1 sangat melelahkan dan rentan kesalahan. Beberapa alat mengotomatiskan deteksi ini di lingkungan pengembangan dan CI.
### Bullet: deteksi waktu nyata
Gem Bullet menganalisis query SQL secara waktu nyata dan memberikan peringatan saat masalah N+1 terdeteksi. Gem ini juga menyarankan perbaikan yang sesuai.
```ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
end
```
Konfigurasi pada lingkungan pengembangan memungkinkan untuk mengaktifkan berbagai mode peringatan.
```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
```
Ketika sebuah masalah N+1 terdeteksi, Bullet menampilkan pesan eksplisit beserta solusi yang direkomendasikan.
```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 di CI**
>
> Dalam continuous integration, mengaktifkan `Bullet.raise = true` membuat tes gagal jika masalah N+1 terdeteksi. Hal ini mencegah regresi kinerja.
### Prosopite: alternatif ringan
Gem Prosopite menawarkan alternatif yang lebih ringan dibanding Bullet, dengan konfigurasi minimal dan kompatibilitas dengan tes.
```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
```
## Teknik optimisasi lanjutan
Selain metode dasar, beberapa teknik memungkinkan penyetelan optimisasi query ActiveRecord secara lebih halus.
### Strict Loading: pencegahan secara default
Rails 6.1+ menyediakan mode strict loading yang melempar pengecualian jika sebuah asosiasi yang tidak dimuat terlebih dahulu diakses. Pendekatan preventif ini memaksa penyelesaian N+1 selama tahap pengembangan.
```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
```
Strict loading juga dapat diaktifkan pada query tertentu.
```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 dan Pluck untuk data sebagian
Ketika hanya kolom tertentu yang dibutuhkan, `select` dan `pluck` mengurangi jumlah data yang ditransfer dari basis data.
```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 untuk penghitungan
Penghitungan asosiasi (`article.comments.count`) menghasilkan satu query SQL pada setiap pemanggilan. Counter cache menyimpan hitungan ini langsung di tabel induk.
```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
```
Migrasi menambahkan kolom hitungan dengan nilai default.
```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
```
Setelah konfigurasi ini, `article.comments_count` membaca kolom secara langsung tanpa query SQL tambahan.
```ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>