Eager loading

Metode with(), masalah N+1, dan cara mendeteksinya.

Daftar isi

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.

php Pola perulangan yang memicu masalah N+1
// 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.

bash
# 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;
'
text Keluaran
Ide jadwal berburu foto -> Hobi
Daftar buku bacaan -> Hobi
Catatan praktik memotret -> Hobi
Rencana liburan hemat -> Hobi
Jumlah query: 5

Keluaran 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.

bash
# 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;
'
text Keluaran
Ide jadwal berburu foto -> Hobi
Daftar buku bacaan -> Hobi
Catatan praktik memotret -> Hobi
Rencana liburan hemat -> Hobi
Jumlah query: 2

Jumlah 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.

Tanpa with() (lazy)query 1: ambil 4 catatanquery 2: kategori catatan 1query 3: kategori catatan 2query 4 dan 5: dst.total: 5 queryDengan with() (eager)query 1: ambil 4 catatanquery 2: ambil semua kategorirelasi sudah terisi sebelum perulangantotal: 2 queryJumlah query eager loading tetap sama seberapa pun banyak catatannya.
Perbandingan jumlah query tanpa dan dengan eager loading

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.

bash
# 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;
'
text Keluaran
Hobi
Hobi
Hobi
Hobi
Jumlah query: 2

Total 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.

bash
# 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);
'
text Keluaran
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.

php Pengaturan shouldBeStrict pada AppServiceProvider
// 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, sedangkan load() mengisi relasi pada collection yang sudah terbentuk dengan total query yang sama.
  • DB::enableQueryLog() dan DB::getQueryLog() mengukur jumlah query sehingga perbaikan dapat dibandingkan secara objektif.
  • Model::preventLazyLoading() dan Model::shouldBeStrict() mengubah lazy loading menjadi LazyLoadingViolationException, dan keduanya dipasang pada boot() di AppServiceProvider.

Referensi#