Langsung ke konten
N+1 Query Problem di Prisma dan Laravel Eloquent: Studi Kasus dan Cara Saya Memperbaikinya
Kembali ke Blog
Backend Development·6 menit baca·

N+1 Query Problem di Prisma dan Laravel Eloquent: Studi Kasus dan Cara Saya Memperbaikinya

Query N+1 problem sering lolos dari code review dan baru terasa saat data membesar. Ini studi kasus nyata optimasi eager loading di Prisma dan Laravel Eloquent.

TH
Tomi Hartanto·Senior Software Engineer

Minggu lalu saya diminta tolong lihat endpoint dashboard yang responsenya lambat: 4,2 detik untuk 50 order. Bukan bug, bukan network — cuma 213 query SQL yang dijalankan untuk satu request. Klasik N+1 query problem, dan yang bikin saya agak kesal adalah pola kodenya lolos dari code review tiga kali karena terlihat "bersih". Makanya saya tulis artikel ini: N+1 adalah masalah ORM yang paling sering saya temui, baik di Prisma (Node.js) maupun Laravel Eloquent (PHP), dan cara memperbaikinya hampir selalu sama.

Apa Itu N+1 dan Kenapa Kamu Tidak Menyadarinya

Idenya sederhana: kamu fetch N record induk (1 query), lalu di loop kamu akses relasi tiap record — dan ORM diam-diam menjalankan satu query tambahan per record. Totalnya 1 + N query.

Masalahnya, kode penyebabnya justru terlihat paling idiomatis. Contoh di Eloquent:

$orders = Order::where('status', 'paid')->take(50)->get();

foreach ($orders as $order) {
    echo $order->customer->name; // 1 query per order!
}

Di level lokal dengan 10 baris data dan seeder, semuanya terasa instan. Latensi query kecil (0,5–2 ms per query) tidak terasa sampai N besar dan kamu sudah di production dengan network round-trip ke database terpisah. Masalah N+1 bukan temuan baru — diskusi di komunitas ORM sudah ada sejak lama, misalnya pertanyaan klasik di Stack Overflow tentang optimasi N+1 di Hibernate yang isinya persis pola yang sama, hanya beda ORM.

Diagram 1 query induk diikuti banyak query kecil per baris yang menumpuk

Studi Kasus 1: Prisma dan Method include

Di proyek SaaS multi-tenant yang saya kerjakan, ada endpoint yang menampilkan daftar invoice beserta item dan customer. Versi lambatnya begini:

const invoices = await prisma.invoice.findMany({
  where: { tenantId },
  take: 50,
});

for (const inv of invoices) {
  const items = await prisma.invoiceItem.findMany({
    where: { invoiceId: inv.id },
  });
  inv.items = items;
}

50 invoice berarti 51 round-trip ke Postgres. Perbaikannya satu baris — include:

const invoices = await prisma.invoice.findMany({
  where: { tenantId },
  take: 50,
  include: {
    items: true,
    customer: { select: { id: true, name: true } },
  },
});

Prisma mengubahnya menjadi dua query (satu untuk invoice, satu dengan IN (...) untuk relasi) lalu menggabungkannya di client. Dari ~4,2 detik turun ke 180 ms di environment staging saya.

Jebakan yang Sering Kena: N+1 di Dalam Loop Async

Yang lebih licik adalah N+1 yang tersembunyi di layer service atau serializer. Contoh nyata yang pernah saya temui:

const orders = await prisma.order.findMany({ take: 50 });

const results = await Promise.all(
  orders.map(async (o) => ({
    ...o,
    approval: await prisma.approval.findUnique({
      where: { orderId: o.id },
    }),
  }))
);

Promise.all membuatnya terlihat "concurrent", tapi tetap 50 query paralel — dan di connection pool kecil (default Prisma connection_limit bisa habis), malah berujung antrean pool. Solusinya tetap include, atau kalau relasinya unik, groupBy manual dan mapping di memori.

Ilustrasi connection pool database yang penuh oleh banyak query paralel

Studi Kasus 2: Laravel Eloquent dan with()

Eloquent punya lazy loading by default — relasi diakses baru di-query saat dipanggil. Praktis untuk development, berbahaya untuk loop. Perbaikannya adalah eager loading:

$orders = Order::with('customer', 'items.product')
    ->where('status', 'paid')
    ->take(50)
    ->get();

foreach ($orders as $order) {
    echo $order->customer->name; // tidak ada query tambahan
}

Eloquent menjalankan query induk, lalu query WHERE IN per relasi yang di-eager load. Nested relasi (items.product) juga teratasi.

Langkah Ekstra: Larang Lazy Loading di Production

Setelah dua kali kena insiden ini, sekarang saya selalu menyalakan Model::preventLazyLoading() di semua environment kecuali local:

// AppServiceProvider.php
public function boot(): void
{
    Model::preventLazyLoading(!app()->isLocal());
}

Dengan ini, akses relasi yang lupa di-eager load langsung melempar exception, bukan diam-diam menjalankan query. Exception di staging jauh lebih murah daripada 4 detik response time di production. Kelemahannya: kalau ada kode lama yang mengandalkan lazy loading secara sadar (jarang, tapi ada), kamu harus memperbaikinya dulu sebelum mengaktifkan.

Cara Mendeteksi Sebelum Kena di Production

Query Logging

  • Prisma: set log: ['query'] di PrismaClient saat development, atau pakai prisma.$extends untuk menghitung query per request.
  • Laravel: DB::listen() atau package debugbar; di production saya log query yang jumlahnya per request melewati threshold (misal >20) sebagai warning.
// Middleware penghitung query Prisma
const prisma = new PrismaClient({ log: [{ emit: 'event', level: 'query' }] });

prisma.$on('query', async (e) => {
  if (queryCountThisRequest++ > 20) {
    logger.warn('Possible N+1 detected', { requestId, queryCountThisRequest });
  }
});

Penghitung sederhana ini sudah menangkap tiga regresi di tim saya dalam enam bulan terakhir.

Tes dengan Data Realistis

Seeder dengan 5 baris data tidak akan pernah menampakkan N+1. Kebiasaan saya: seeder minimal 1.000 baris untuk entity utama. Kalau endpoint list lambat di local dengan 1.000 baris, hampir pasti ada masalah query.

Kapan Eager Loading Justru Tidak Layak

Eager loading bukan silver bullet. include: { items: true } pada relasi one-to-many yang sangat besar (order dengan ribuan item) akan menarik semua baris itu ke memori aplikasi padahal kamu cuma butuh total harga. Untuk kasus itu, aggregate di level database lebih tepat:

const invoices = await prisma.invoice.findMany({
  where: { tenantId },
  include: {
    _count: { select: { items: true } }, // COUNT(*) di DB, bukan fetch semua
  },
});

Begitu juga di Eloquent: withCount('items') daripada with('items') kalau kamu cuma butuh angka. Aturan praktis saya: eager loading untuk data yang benar-benar akan ditampilkan per baris, aggregate untuk yang cuma butuh ringkasan.

Ada satu trade-off lagi: query IN dengan daftar ID yang sangat panjang (ribuan) bisa bikin query plan memburuk. Untuk list besar, pagination dengan cursor biasanya sudah membatasi N sehingga ini jarang jadi masalah — tapi patut diingat kalau kamu eager loading untuk batch job. Kalau kamu tertarik pola menangani proses berat di luar request cycle, saya pernah menulis tentang background job dan queue dengan BullMQ yang relevan untuk kasus batch seperti ini.

Takeaway yang Bisa Kamu Pakai Hari Ini

  1. Audit endpoint list kamu hari ini dengan query logging aktif — hitung query per request, bukan durasi total.
  2. Pasang guard otomatis: preventLazyLoading di Laravel, penghitung query di middleware Prisma.
  3. Seeder 1.000+ baris untuk entity utama sebelum menganggap endpoint "cukup cepat".
  4. Ingat bahwa Promise.all tidak menyembuhkan N+1 — hanya menyamarkannya.
  5. Untuk relasi besar, pakai _count/withCount atau aggregate, bukan fetch semua lalu hitung di aplikasi.

N+1 bukan masalah yang sulit diperbaiki — satu baris include atau with() sering cukup. Yang sulit adalah menyadarinya sebelum customer-mu menyadarinya.

Sumber

TH

Tomi Hartanto

Senior Software Engineer

Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.