Satu halaman yang menampilkan data berelasi dapat memicu puluhan query database tanpa terlihat pada permintaannya. Topik ini membahas pola N+1 pada Eloquent, pengukuran jumlah query yang sesungguhnya dieksekusi, perbaikannya melalui eager loading, deteksi otomatis dengan Debugbar, serta pencegahan lazy loading agar pola tersebut tidak masuk kembali ke kode. Setelah topik ini dibaca, masalah query pada halaman daftar aplikasi catatan dapat diperiksa secara terukur dan dituntaskan pada sumbernya.
Pola N+1 pada perulangan relasi#
Pola N+1 adalah pola query yang terdiri atas satu query daftar disusul satu query relasi untuk setiap model yang diambil. Penyebabnya adalah lazy loading, yakni mekanisme Eloquent yang memuat relasi ketika propertinya diakses pertama kali, satu query untuk setiap model induk. Mekanisme ini ringan pada data kecil, tetapi jumlah query tumbuh seiring jumlah baris yang diulang. Pada aplikasi catatan, pola ini muncul pada halaman daftar kategori yang menampilkan setiap kategori beserta catatannya.
Pengukuran dimulai dari data uji yang deterministik, yaitu tiga kategori bawaan seed dengan dua catatan uji pada masing-masingnya.
# menyiapkan data uji deterministik pada aplikasi catatan
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
foreach (["Kerja", "Belajar", "Pribadi"] as $nama) {
$kategori = Category::firstOrCreate(["nama" => $nama]);
for ($i = 1; $i <= 2; $i++) {
Note::firstOrCreate(
["judul" => "Catatan uji N+1 {$nama} {$i}"],
["isi" => "Isi catatan untuk uji pola N+1.", "category_id" => $kategori->id]
);
}
}
echo "Data uji siap." . PHP_EOL;
'Data uji siap.Jumlah query dihitung dengan DB::enableQueryLog(), yaitu perintah yang mencatat seluruh query sejak dipanggil hingga DB::getQueryLog() membacanya. Pada blok berikut, relasi notes diakses di dalam perulangan tanpa persiapan apa pun, persis seperti pola yang sering muncul pada controller.
# mengukur jumlah query ketika relasi dimuat di dalam perulangan
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Support\Facades\DB;
use App\Models\Category;
DB::enableQueryLog();
$kategoris = Category::whereIn("nama", ["Kerja", "Belajar", "Pribadi"])->get();
foreach ($kategoris as $kategori) {
$kategori->notes;
}
echo "Jumlah kategori: " . $kategoris->count() . PHP_EOL;
echo "Jumlah query: " . count(DB::getQueryLog()) . PHP_EOL;
'Jumlah kategori: 3
Jumlah query: 4Empat query itu terdiri atas satu query daftar kategori dan tiga query catatan yang masing-masing menyaring category_id untuk satu nilai. Bentuk umumnya adalah satu query ditambah N query relasi, sehingga tabel kategori yang berisi 100 baris akan menghasilkan 101 query untuk satu halaman. Waktu yang hilang bukan pada satu query, melainkan pada perjalanan bolak-balik ke database yang diulang ratusan kali.
Eager loading dengan with() dan withCount()#
Eager loading menurunkan jumlah query tersebut dengan memuat relasi bersama model induknya. Metode with("notes") menunda eksekusi hingga daftar induk diperoleh, lalu mengambil seluruh catatan terkait dalam satu query tambahan dengan klausa whereIn. Eloquent kemudian menyusun kembali hasilnya pada setiap model induk di memori, sehingga akses relasi di dalam perulangan tidak lagi menyentuh database.
# memuat relasi bersama model induk menggunakan with()
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Support\Facades\DB;
use App\Models\Category;
DB::enableQueryLog();
$kategoris = Category::whereIn("nama", ["Kerja", "Belajar", "Pribadi"])->with("notes")->get();
foreach ($kategoris as $kategori) {
$kategori->notes;
}
echo "Jumlah query with(): " . count(DB::getQueryLog()) . PHP_EOL;
'Jumlah query with(): 2Jumlah query tersebut tetap dua berapa pun jumlah kategori yang diambil, karena query kedua menyaring category_id untuk seluruh kunci induk sekaligus. Diagram berikut membandingkan kedua bentuk alurnya.
withCount("notes") menambahkan kolom agregat notes_count pada setiap hasil melalui subquery, sehingga seluruh perhitungan selesai dalam satu query tanpa satu pun model Note dimuat.
# menghitung relasi tanpa memuat modelnya menggunakan withCount()
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Support\Facades\DB;
use App\Models\Category;
DB::enableQueryLog();
$kategoris = Category::whereIn("nama", ["Kerja", "Belajar", "Pribadi"])->withCount("notes")->get();
echo "Jumlah query withCount(): " . count(DB::getQueryLog()) . PHP_EOL;
'Jumlah query withCount(): 1Dua variasi lain melengkapi metode tersebut. Metode load() memuat relasi pada koleksi yang sudah diambil, misalnya Category::all()->load("notes"), sedangkan notasi titik memuat relasi bertingkat, misalnya with("notes.category") untuk mengambil kategori dari setiap catatan sekaligus. Kedua cara itu dipanggil secara manual pada kode, sedangkan halaman web membutuhkan alat yang membaca query tanpa menyunting kode.
Deteksi N+1 dengan Debugbar#
Pengukuran dengan DB::enableQueryLog() dilakukan secara manual pada skrip, sedangkan query halaman web dieksekusi di dalam siklus permintaan HTTP. Debugbar, yaitu package barryvdh/laravel-debugbar, menempelkan toolbar pada setiap halaman saat aplikasi berjalan dengan debug aktif. Tab Queries pada toolbar tersebut menampilkan setiap query beserta durasinya, jumlah total query, dan penanda pada query yang berulang, sehingga pola N+1 terlihat tanpa pencatatan manual pada controller.
# tidak-dijalankan
# menginstal Debugbar hanya untuk lingkungan pengembangan
composer require barryvdh/laravel-debugbar --dev
php artisan vendor:publish --provider="Barryvdh\Debugbar\ServiceProvider"Debugbar hanya dimuat ketika APP_DEBUG=true pada berkas .env, sehingga toolbar tidak muncul di server produksi yang debugnya dimatikan. Langkah penggunaannya pada aplikasi catatan sebagai berikut.
- Instal package tersebut sebagai dependensi pengembangan sebagaimana perintah di atas.
- Buka halaman daftar kategori pada browser, lalu amati tab Queries di toolbar.
- Bandingkan jumlah query sebelum dan sesudah
with("notes")ditambahkan, kemudian periksa penanda pada query yang berulang.
Pencegahan lazy loading#
Perbaikan pada satu halaman tidak melindungi halaman yang ditulis kemudian, sehingga pencegahannya perlu berlaku secara menyeluruh. Laravel menyediakan Model::preventLazyLoading() yang memindahkan akses relasi yang belum dimuat dari perilaku senyap menjadi exception. Pendaftarannya dilakukan sekali pada boot() di app/Providers/AppServiceProvider.php, lazimnya hanya aktif di luar produksi.
// tidak-dijalankan
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}Dengan pendaftaran tersebut, setiap akses relasi yang belum dimuat pada lingkungan pengembangan melempar LazyLoadingViolationException beserta nama model dan relasinya. Pencegahan ini hanya menargetkan relasi yang belum dimuat, sehingga akses yang sudah disertai with() tetap berjalan tanpa exception. Perilaku ini dapat dibuktikan langsung pada tinker dengan mengaktifkan pencegahan lalu mengakses relasi dari hasil koleksi.
# membuktikan akses relasi yang dimuat malas melempar exception
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\LazyLoadingViolationException;
use App\Models\Category;
Model::preventLazyLoading();
try {
Category::whereIn("nama", ["Kerja", "Belajar", "Pribadi"])->get()->first()->notes;
} catch (LazyLoadingViolationException $e) {
echo "Exception tertangkap: " . get_class($e) . PHP_EOL;
echo "Pesan: " . $e->getMessage() . PHP_EOL;
}
'Exception tertangkap: Illuminate\Database\LazyLoadingViolationException
Pesan: Attempted to lazy load [notes] on model [App\Models\Category] but lazy loading is disabled.Exception pada lingkungan pengembangan memaksa pola N+1 diperbaiki sebelum rilis. Apabila pencegahan tetap diaktifkan pada produksi, ubah penanganan pelanggarannya dengan Model::handleLazyLoadingViolationUsing() agar permintaan pengguna tidak ikut berhenti.
# mengubah pelanggaran menjadi pencatatan alih-alih exception
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Database\Eloquent\Model;
use App\Models\Category;
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function ($model, $key, $exception) {
echo "Pelanggaran dicatat: " . $model->getMorphClass() . "." . $key . PHP_EOL;
});
Category::whereIn("nama", ["Kerja", "Belajar", "Pribadi"])->get()->first()->notes;
echo "Eksekusi tetap berlanjut tanpa exception." . PHP_EOL;
'Pelanggaran dicatat: App\Models\Category.notes
Eksekusi tetap berlanjut tanpa exception.Callback tersebut menerima model, nama relasi, dan objek exception, sehingga isi callback yang lazim adalah report($exception) agar setiap pelanggaran masuk ke log aplikasi. Halaman yang bermasalah tetap dilayani, sementara jejak pelanggarannya dapat diselidiki kemudian. Uji fitur yang dijalankan ketika pencegahan aktif turut menjaga agar pola N+1 tidak lolos, karena exception yang muncul menggagalkan pengujian sebelum kode sampai ke produksi. Satu langkah lebih ketat tersedia melalui Model::shouldBeStrict(), yang mengaktifkan pencegahan lazy loading sekaligus larangan mengakses atribut yang tidak ada.
Kesalahan umum#
Kesalahan pertama adalah menjalankan query induk di dalam perulangan, misalnya Note::find($id) untuk setiap elemen daftar. Bentuknya tidak melibatkan relasi sama sekali, tetapi polanya tetap N+1 karena satu query dieksekusi per elemen. Perbaikannya adalah mengambil seluruh id dengan whereIn atau menyerahkan pemuatan relasinya kepada with() pada query pertama.
Kesalahan kedua adalah mengeja nama relasi secara berbeda antara with() dan akses propertinya, misalnya memuat with("category") tetapi mengakses $note->categories. Eloquent memperlakukan properti yang salah itu sebagai relasi yang belum dimuat, sehingga lazy loading berjalan kembali tanpa terlihat. Pencegahan lazy loading memperlihatkan kesalahan ini seketika dalam bentuk exception, dan perbaikannya adalah menyamakan nama relasi dengan nama method pada model.
Kesalahan ketiga adalah memuat semua relasi yang tersedia hanya karena with() terlihat cepat. Rantai seperti with("notes.category") pada halaman yang hanya menampilkan judul catatan menambah query dan memori untuk relasi yang tidak dirender sama sekali. Eager loading yang baik mengikuti kebutuhan tampilan, yaitu hanya relasi yang benar-benar digunakan pada halaman tersebut.
Ringkasan#
- Pola N+1 berasal dari lazy loading, yaitu satu query daftar disusul satu query relasi per model sehingga jumlahnya 1 + N.
with()menurunkan jumlah query menjadi dua berapa pun jumlah induknya, sedangkanwithCount()menghitung relasi dalam satu query tanpa memuat modelnya.- Debugbar mencatat seluruh query setiap permintaan web dan menandai query yang berulang, sehingga deteksi tidak bergantung pada pencatatan manual.
Model::preventLazyLoading()padaboot()AppServiceProvider melemparLazyLoadingViolationExceptionsejak lingkungan pengembangan, danhandleLazyLoadingViolationUsing()mengubahnya menjadi pencatatan.
Referensi#
- Eloquent: Relationships — Laravel — Dokumentasi eager loading, eager loading bertingkat, dan pencegahan lazy loading.
- Database: Query Builder — Laravel
— Dokumentasi pencatatan query melalui
DB::enableQueryLog()danDB::getQueryLog(). - Laravel Debugbar — GitHub — Repository package Debugbar beserta perilaku toolbar dan tab queries-nya.