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

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

<% end %> ``` ## Praktik terbaik dan checklist Pendekatan sistematis mencegah masalah N+1 pada pengembangan baru dan secara bertahap memperbaiki kode yang ada. ### Analisis view sebelum menulis kode Sebelum menulis kode controller, sebaiknya analisis view untuk mengidentifikasi semua asosiasi yang diakses. Antisipasi ini menghindari kelalaian. ```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 yang dapat digunakan ulang Memusatkan includes yang sering digunakan ke dalam scope menyederhanakan pemeliharaan dan memastikan konsistensi. ```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 ``` ### Checklist pencegahan Checklist ini merangkum poin verifikasi penting untuk menghindari masalah N+1: - Pasang dan konfigurasikan Bullet atau Prosopite di pengembangan - Aktifkan Bullet.raise di CI untuk memblokir regresi - Analisis view untuk mengidentifikasi asosiasi sebelum menulis controller - Gunakan `includes` secara default, `eager_load` jika ada penyaringan pada asosiasi - Buat scope yang dapat digunakan ulang untuk pola pemuatan yang sering muncul - Terapkan strict loading pada model yang sensitif - Tambahkan counter cache untuk penghitungan yang sering - Periksa log SQL secara berkala selama pengembangan > **Hati-hati pemuatan berlebihan** > > Memuat terlalu banyak asosiasi terlebih dahulu menghabiskan memori secara tidak perlu. Hanya asosiasi yang benar-benar digunakan oleh view yang sebaiknya dimuat. Alat seperti Bullet juga mendeteksi "unused eager loading". ## Kesimpulan Query N+1 merupakan masalah kinerja yang signifikan dan mudah dicegah pada aplikasi Rails. Kombinasi alat deteksi otomatis dan praktik pengembangan yang baik menghilangkan masalah ini secara efektif. **Poin-poin utama:** - `includes` menyelesaikan sebagian besar kasus N+1 dengan memuat asosiasi terlebih dahulu - `eager_load` diperlukan saat memfilter atau mengurutkan berdasarkan asosiasi - Bullet dan Prosopite secara otomatis mendeteksi masalah selama pengembangan - Strict loading mencegah N+1 dengan melempar pengecualian - Counter cache mengoptimalkan penghitungan yang sering - Menganalisis view sebelum menulis controller mencegah kelalaian --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/id/blog/ruby-on-rails/activerecord-fixing-n-plus-1-query-problems