Topik ini membahas masalah N+1 pada relasi Eloquent beserta penyelesaiannya. Masalah tersebut muncul ketika sekumpulan model mengambil relasinya satu per satu di dalam perulangan, sehingga jumlah query membengkak meskipun kode yang menuliskannya tampak benar. Bahasan mencakup pengukuran jumlah query dengan query log, metode with() dan load() untuk eager loading, serta pengetatan Model::preventLazyLoading() dan Model::shouldBeStrict() yang mendeteksi lazy loading sejak masa pengembangan. Contoh menggunakan relasi Note ke Category yang sudah terdaftar pada aplikasi catatan.
Masalah N+1#
Masalah N+1 adalah pola query yang terdiri atas satu query untuk mengambil sekumpulan baris dan N query tambahan untuk mengambil relasi dari setiap baris. Pola ini tidak memunculkan error apa pun, sehingga kode yang memicunya lolos uji fungsional dan baru tampak ketika respons aplikasi melambat. Karena jumlah query-nya tumbuh sebanding dengan jumlah baris, biayanya paling terasa pada daftar yang panjang dan pada koneksi database yang berjarak jauh dari aplikasi.
Pemicunya adalah akses properti relasi di dalam perulangan. Properti category pada model Note menjalankan query saat pertama kali diakses pada setiap objek, sehingga setiap iterasi yang membacanya mengirim satu query baru. Potongan berikut memperlihatkan pola yang menimbulkan masalah tersebut.
// tidak-dijalankan
$catatan = Note::where("category_id", $idHobi)->get();
foreach ($catatan as $c) {
echo $c->judul . " milik kategori " . $c->category->nama . PHP_EOL;
}Kode di atas benar dari sisi hasil, tetapi buruk dari sisi biaya. Setiap query membawa biaya tetap berupa parsing SQL, perjalanan bolak-balik (round trip) antara aplikasi dan server database, serta pemrosesan hasilnya. Apabila jumlah catatan naik dari empat menjadi empat ribu, jumlah query naik dari lima menjadi empat ribu satu, sedangkan satu query kategori sebenarnya cukup untuk menangani kedua kasus tersebut.
Mengukur jumlah query#
Sebelum memperbaiki, jumlah query perlu diukur agar perbaikan dapat dibandingkan secara objektif. Laravel menyediakan query log melalui facade DB: metode enableQueryLog() mulai merekam setiap query yang dijalankan koneksi, sedangkan getQueryLog() mengembalikan array rekamannya sehingga jumlahnya dapat dihitung dengan count(). Blok berikut menyiapkan empat catatan deterministik pada kategori Hobi, lalu mengukur pola perulangan pada bagian sebelumnya.
# menyiapkan data uji lalu mengukur jumlah query tanpa eager loading
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use Illuminate\Support\Facades\DB;
$hobi = Category::firstOrCreate(["nama" => "Hobi"]);
Note::where("category_id", $hobi->id)->delete();
foreach (["Ide jadwal berburu foto", "Daftar buku bacaan", "Catatan praktik memotret", "Rencana liburan hemat"] as $j) {
Note::create(["judul" => $j, "isi" => "Isi catatan hobi: " . $j . ".", "category_id" => $hobi->id]);
}
DB::enableQueryLog();
$catatan = Note::where("category_id", $hobi->id)->get();
foreach ($catatan as $c) {
echo $c->judul . " -> " . $c->category->nama . PHP_EOL;
}
echo "Jumlah query: " . count(DB::getQueryLog()) . PHP_EOL;
'Ide jadwal berburu foto -> Hobi
Daftar buku bacaan -> Hobi
Catatan praktik memotret -> Hobi
Rencana liburan hemat -> Hobi
Jumlah query: 5Keluaran memperlihatkan lima query untuk empat baris, yaitu satu query untuk mengambil catatan dan empat query untuk mengambil kategori masing-masing catatan. Pembersihan dan pembuatan ulang data di awal blok menjaga keluaran tetap sama setiap kali blok dijalankan ulang. Angka inilah yang menjadi pembanding untuk metode with() pada bagian berikutnya.
Eager loading dengan with()#
Eager loading menyelesaikan masalah tersebut dengan mengambil data relasi dalam query terpisah sebelum perulangan dimulai. Metode with() dipanggil pada query sebelum get() dan menerima nama metode relasi sebagai argumennya. Eloquent menjalankan dua query untuk contoh ini, yaitu satu query catatan dan satu query kategori dengan klausa whereIn yang mencakup seluruh category_id dari hasil pertama. Properti category pada setiap model kemudian diisi sebelum hasil dikembalikan.
# mengukur jumlah query dengan with()
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use Illuminate\Support\Facades\DB;
$hobi = Category::firstOrCreate(["nama" => "Hobi"]);
DB::enableQueryLog();
$catatan = Note::with("category")->where("category_id", $hobi->id)->get();
foreach ($catatan as $c) {
echo $c->judul . " -> " . $c->category->nama . PHP_EOL;
}
echo "Jumlah query: " . count(DB::getQueryLog()) . PHP_EOL;
'Ide jadwal berburu foto -> Hobi
Daftar buku bacaan -> Hobi
Catatan praktik memotret -> Hobi
Rencana liburan hemat -> Hobi
Jumlah query: 2Jumlah query kini konstan, yaitu dua, dan tidak lagi bergantung pada banyaknya catatan. Metode with() juga menerima array untuk beberapa relasi sekaligus serta notasi titik untuk relasi bersarang, misalnya with("category.notes") yang ikut mengambil catatan lain dari setiap kategori. Diagram berikut membandingkan kedua pola query secara berdampingan.
Lazy eager loading dengan load()#
Pengisian relasi terkadang baru dibutuhkan setelah collection model terbentuk, misalnya ketika kebutuhan tampilan baru muncul di tengah alur program. Metode load() menyelesaikan kondisi itu dengan menjalankan query relasi pada collection yang sudah ada, sehingga pola aksesnya disebut lazy eager loading. Argumennya sama dengan with(), yaitu nama relasi atau array nama relasi, dan mekanisme query-nya pun sama.
# mengisi relasi setelahnya dengan load() lalu mengukur jumlah query
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use Illuminate\Support\Facades\DB;
$hobi = Category::firstOrCreate(["nama" => "Hobi"]);
DB::enableQueryLog();
$catatan = Note::where("category_id", $hobi->id)->get();
$catatan->load("category");
foreach ($catatan as $c) {
echo $c->category->nama . PHP_EOL;
}
echo "Jumlah query: " . count(DB::getQueryLog()) . PHP_EOL;
'Hobi
Hobi
Hobi
Hobi
Jumlah query: 2Total query tetap dua, tetapi komposisinya berbeda dengan with(), yaitu satu query utama diikuti satu query relasi yang dipanggil setelahnya. Perbedaan ini murni soal waktu pemanggilan, sedangkan hasil akhirnya identik, yaitu properti category sudah terisi pada setiap model sebelum properti itu dibaca. Apabila kebutuhan relasi sudah pasti sejak awal, with() lebih disukai karena relasi yang diambil tercantum pada baris query dan mudah ditemukan saat audit. Metode load() lebih tepat ketika kebutuhan itu muncul dari kondisi yang baru terbentuk di tengah alur kode.
Mencegah lazy loading#
Masalah N+1 dapat dicegah sejak sumbernya dengan menutup akses lazy loading. Metode statis preventLazyLoading() pada kelas Illuminate\Database\Eloquent\Model mengubah setiap akses relasi yang belum dimuat menjadi error LazyLoadingViolationException. Dengan pengaturan ini, kode yang lupa menjalankan eager loading tidak lagi terlewati tanpa sinyal, melainkan langsung terdeteksi pada tempat kejadiannya.
# mencegah lazy loading lalu memicu pelanggarannya
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use Illuminate\Database\Eloquent\Model;
Model::preventLazyLoading();
$catatan = Note::where("category_id", Category::firstOrCreate(["nama" => "Hobi"])->id)->get();
echo "Jumlah catatan: " . $catatan->count() . PHP_EOL;
try {
$catatan->first()->category;
} catch (Illuminate\Database\LazyLoadingViolationException $e) {
echo $e->getMessage() . PHP_EOL;
}
Model::preventLazyLoading(false);
'Jumlah catatan: 4
Attempted to lazy load [category] on model [App\Models\Note] but lazy loading is disabled.Pengaturan tersebut berlaku per proses, sehingga tempat yang tepat untuk memasangnya adalah metode boot() pada app/Providers/AppServiceProvider.php agar berlaku untuk setiap permintaan. Dokumentasi Laravel merekomendasikan bentuk berikut, yang menetapkan mode ketat di semua lingkungan kecuali production.
// tidak-dijalankan
use Illuminate\Database\Eloquent\Model;
// di dalam kelas AppServiceProvider
public function boot(): void
{
Model::shouldBeStrict(! app()->isProduction());
}Metode shouldBeStrict() mengaktifkan tiga pengetatan sekaligus, yaitu pencegahan lazy loading, penolakan atribut di luar $fillable dengan error MassAssignmentException, dan penolakan akses atribut yang tidak ada dengan error MissingAttributeException. Argumen ! app()->isProduction() membuat mode ketat hanya berlaku di luar production, sehingga error terdeteksi selama pengembangan tanpa mengubah perilaku aplikasi yang sudah berjalan.
Kesalahan umum#
Kesalahan pertama adalah memberi with() nama kolom alih-alih nama metode relasi, misalnya with("category_id"). Nama yang diterima with() adalah nama metode pada model, sehingga bentuk itu gagal dengan error RelationNotFoundException berupa “Call to undefined relationship [category_id] on model [App\Models\Note]”. Perbaikannya adalah menuliskan nama metodenya, yaitu with("category").
Kesalahan kedua adalah memanggil with() pada hasil query yang sudah berupa collection, misalnya $catatan->with("category"). Metode with() milik query, bukan milik collection, sehingga pemanggilan seperti itu menghasilkan error “Call to undefined method”. Pada collection yang sudah terbentuk, relasi diisi dengan load() sebagaimana dicontohkan pada bagian sebelumnya.
Kesalahan ketiga adalah menganggap satu pemanggilan with() menutup seluruh masalah N+1. Apabila perulangan tetap membaca relasi lain yang tidak disertakan, relasi itu tetap diambil satu per satu. Setiap relasi yang dibaca di dalam perulangan perlu dicantumkan pada with() atau load(), dan pengukuran dengan query log menjadi cara untuk memastikannya.
Ringkasan#
- Masalah N+1 terjadi ketika perulangan membaca properti relasi sehingga jumlah query menjadi satu ditambah jumlah barisnya, dan pola ini tidak memunculkan error apa pun.
with()mengambil relasi sebelum hasil dikembalikan sehingga jumlah query konstan, sedangkanload()mengisi relasi pada collection yang sudah terbentuk dengan total query yang sama.DB::enableQueryLog()danDB::getQueryLog()mengukur jumlah query sehingga perbaikan dapat dibandingkan secara objektif.Model::preventLazyLoading()danModel::shouldBeStrict()mengubah lazy loading menjadiLazyLoadingViolationException, dan keduanya dipasang padaboot()diAppServiceProvider.
Referensi#
- Eloquent: Relationships — Laravel — Dokumentasi eager loading, lazy eager loading, dan pencegahan lazy loading.
- Database: Getting Started — Laravel — Dokumentasi koneksi database beserta pemantauan query pada aplikasi Laravel.