Jika halaman daftar data di CodeIgniter 4 terasa cepat saat data masih sedikit tetapi mulai lambat ketika isi tabel bertambah, salah satu penyebab paling umum adalah N+1 query. Pola ini terjadi ketika aplikasi menjalankan satu query untuk mengambil daftar utama, lalu menjalankan query tambahan untuk setiap baris hasil.

Masalahnya bukan sekadar "query terlalu banyak", tetapi akumulasi latensi database, parsing hasil, dan overhead jaringan atau koneksi. Solusi yang paling sering efektif adalah menggabungkan pengambilan data terkait ke dalam JOIN, memilih kolom seperlunya, membentuk data di sisi aplikasi dengan efisien, dan memastikan kolom relasi atau filter sudah diindeks.

Apa itu N+1 Query dan Kenapa Membuat Halaman List Melambat

N+1 query biasanya muncul pada halaman seperti daftar pesanan, daftar artikel, daftar komentar, atau daftar transaksi yang menampilkan data dari beberapa tabel. Contohnya, aplikasi mengambil 50 pesanan dari tabel orders, lalu untuk setiap pesanan mengambil data pelanggan dari tabel customers dengan query terpisah. Hasilnya: 1 query utama + 50 query tambahan.

Dalam skala kecil ini mungkin tidak terasa. Namun ketika data tumbuh, jumlah query ikut naik linear terhadap jumlah baris. Pada traffic production, dampaknya bisa berupa:

  • Waktu render halaman meningkat saat pagination menampilkan lebih banyak baris.
  • Lonjakan query per request pada endpoint yang sama.
  • Beban database meningkat, terutama saat banyak request paralel.
  • CPU dan I/O database lebih sibuk walau logika aplikasi terlihat sederhana.
  • Variasi response time karena query kecil yang sangat banyak sering lebih buruk daripada satu query yang rapi.

Gejala klasik di production: halaman daftar dengan 10 item terasa normal, tetapi saat dinaikkan ke 50 atau 100 item per halaman, response time naik tajam.

Contoh Pola Kode yang Memicu N+1 Query

Contoh kasus: daftar order dan nama customer

Misalkan Anda ingin menampilkan daftar order beserta nama customer. Pola berikut terlihat mudah ditulis, tetapi memicu N+1 query.

<?php

namespace App\Controllers;

use App\Models\OrderModel;
use App\Models\CustomerModel;

class Orders extends BaseController
{
    public function index()
    {
        $orderModel = new OrderModel();
        $customerModel = new CustomerModel();

        $orders = $orderModel
            ->orderBy('created_at', 'DESC')
            ->findAll(50);

        foreach ($orders as &$order) {
            $order['customer'] = $customerModel->find($order['customer_id']);
        }

        return view('orders/index', ['orders' => $orders]);
    }
}

Masalahnya ada di dalam foreach. Setiap iterasi memanggil find() ke tabel customer. Jika ada 50 order, maka total query menjadi 51.

Pola serupa juga sering muncul di view, misalnya ketika helper atau model dipanggil di dalam loop untuk mengambil data relasi. Ini lebih berbahaya karena sulit terlihat saat review kode.

Kenapa pola ini mahal

  • Setiap query punya overhead sendiri, meskipun query-nya sederhana.
  • Database harus memproses banyak operasi kecil dibanding satu query yang lebih terstruktur.
  • Latensi koneksi dan transfer hasil berulang kali menambah waktu total request.
  • Skalanya buruk: semakin banyak item, semakin banyak query.

Cara Menemukan Bottleneck N+1 Query di CodeIgniter 4

1. Aktifkan profiler atau debug toolbar di environment pengembangan

Untuk mendeteksi N+1 query, Anda perlu melihat jumlah query dan waktu eksekusi per request. Di CodeIgniter 4, pendekatan paling aman adalah memakai fitur debugging pada environment development, bukan production.

Pastikan environment aplikasi berada di mode development saat melakukan investigasi lokal atau di staging. Setelah itu, muat halaman yang dicurigai lambat dan perhatikan bagian database pada toolbar/profiler.

Yang perlu dibaca:

  • Total jumlah query dalam satu request.
  • Daftar query yang berulang dengan pola SQL yang sama.
  • Total waktu database dibanding total waktu request.
  • Query yang paling mahal atau paling sering dijalankan.

Jika Anda melihat query SELECT * FROM customers WHERE id = ? muncul puluhan kali dalam satu request daftar, itu hampir pasti indikasi N+1 query.

2. Gunakan logging query saat perlu analisis lebih detail

Selain profiler, Anda bisa melakukan inspeksi query dari objek koneksi database di titik tertentu pada controller atau service. Ini berguna saat Anda ingin memastikan query apa saja yang benar-benar dijalankan untuk satu aksi.

<?php

$db = \Config\Database::connect();

// jalankan proses yang ingin dianalisis

$queries = $db->getQueries();

foreach ($queries as $query) {
    log_message('debug', (string) $query);
}

Pendekatan ini cocok untuk lingkungan pengembangan atau staging. Hindari menyalakan logging terlalu agresif di production karena bisa menambah I/O log dan membuat analisis utama jadi bias.

3. Ukur dengan skenario yang realistis

Jangan hanya menguji saat tabel masih berisi sedikit data. Untuk membuktikan bottleneck N+1 query, uji dengan kondisi yang mendekati nyata:

  • Jumlah item per halaman sesuai production, misalnya 25, 50, atau 100.
  • Data relasi benar-benar ada, bukan data dummy yang kosong.
  • Filter dan sorting aktif jika memang digunakan user.
  • Bandingkan request yang sama sebelum dan sesudah refactor.

Refactor N+1 Query Menjadi JOIN

Contoh perbaikan dengan Query Builder

Alih-alih mengambil customer satu per satu, gabungkan data order dan customer dalam satu query menggunakan JOIN.

<?php

namespace App\Controllers;

use App\Models\OrderModel;

class Orders extends BaseController
{
    public function index()
    {
        $orderModel = new OrderModel();

        $orders = $orderModel
            ->select('orders.id, orders.order_number, orders.total, orders.created_at, customers.id AS customer_id, customers.name AS customer_name')
            ->join('customers', 'customers.id = orders.customer_id', 'left')
            ->orderBy('orders.created_at', 'DESC')
            ->findAll(50);

        return view('orders/index', ['orders' => $orders]);
    }
}

Dengan pendekatan ini, data yang tadinya membutuhkan banyak query dapat diambil dalam satu query utama. Pengurangan query biasanya sangat signifikan pada halaman list.

Mengapa JOIN bekerja lebih baik

  • Database dirancang untuk menggabungkan data antar tabel secara efisien.
  • Aplikasi hanya melakukan satu kali round-trip ke database untuk kebutuhan utama halaman.
  • Perencanaan eksekusi query lebih mudah dianalisis dibanding puluhan query kecil.
  • Jumlah query tidak lagi bertambah seiring jumlah baris hasil.

Perbandingan sebelum dan sesudah

Sebelum:

  • 1 query mengambil daftar order.
  • N query tambahan mengambil customer per order.
  • Total: 1 + N query.

Sesudah:

  • 1 query dengan JOIN untuk order dan customer.
  • Total: 1 query utama untuk kebutuhan halaman.

Dalam praktik nyata, hasil yang perlu Anda cek bukan hanya lebih sedikit query, tetapi juga total waktu request dan konsistensi performa saat jumlah data bertambah.

Selective Column dan Eager Data Shaping

Hindari SELECT *

Setelah refactor ke JOIN, kesalahan berikutnya yang sering muncul adalah mengambil terlalu banyak kolom. Untuk halaman list, ambil hanya data yang benar-benar ditampilkan atau dipakai untuk logika ringan.

Contoh yang lebih baik:

<?php

$orders = $orderModel
    ->select('orders.id, orders.order_number, orders.status, orders.total, customers.name AS customer_name')
    ->join('customers', 'customers.id = orders.customer_id', 'left')
    ->where('orders.status', 'paid')
    ->orderBy('orders.created_at', 'DESC')
    ->findAll(50);

Manfaatnya:

  • Transfer data dari database lebih kecil.
  • Penggunaan memori PHP lebih hemat.
  • Risiko bentrok nama kolom lebih rendah jika Anda memberi alias dengan jelas.

Bentuk data sekali, bukan query berkali-kali

Eager data shaping di sini berarti Anda menyiapkan struktur data yang dibutuhkan view dalam satu alur pengambilan data, bukan memanggil query tambahan saat render. Misalnya, jika view hanya perlu customer_name, maka kirim kolom itu langsung dari query, bukan seluruh objek customer.

Contoh data hasil yang siap pakai untuk view:

<?php

$data = array_map(static function ($row) {
    return [
        'id' => $row['id'],
        'order_number' => $row['order_number'],
        'status' => $row['status'],
        'total' => $row['total'],
        'customer_name' => $row['customer_name'],
    ];
}, $orders);

Ini bukan wajib untuk semua kasus, tetapi berguna jika Anda ingin memisahkan data mentah database dari struktur yang dikonsumsi template atau API response.

Indexing Dasar untuk Kolom Relasi dan Filter

JOIN yang rapi tetap bisa lambat jika kolom yang dipakai untuk relasi atau filter tidak memiliki indeks yang memadai. Untuk kasus daftar data, periksa minimal:

  • Kolom relasi seperti orders.customer_id.
  • Kolom primary key pada tabel tujuan join, misalnya customers.id.
  • Kolom filter yang sering dipakai, misalnya orders.status atau orders.created_at.
  • Kombinasi kolom untuk pola query yang sering, jika memang diperlukan.

Contoh prinsipnya:

  • Foreign key atau kolom relasi sebaiknya dapat dicari dengan cepat.
  • Kolom filter dan sorting perlu dievaluasi jika sering muncul pada query halaman list.
  • Jangan menambah indeks berlebihan karena setiap indeks juga punya biaya pada operasi insert/update.

Indeks membantu database menemukan dan menggabungkan baris lebih cepat, tetapi bukan pengganti desain query yang buruk. N+1 query tetap masalah meskipun kolom relasinya sudah diindeks.

Risiko Over-Fetching Setelah Pindah ke JOIN

JOIN bukan berarti selalu aman tanpa efek samping. Salah satu risiko yang sering muncul adalah over-fetching, yaitu mengambil lebih banyak data daripada yang dibutuhkan.

Contoh kasus:

  • Mengambil seluruh kolom customer padahal hanya butuh nama.
  • Menambahkan beberapa JOIN sekaligus untuk relasi yang tidak ditampilkan.
  • Menggabungkan tabel detail one-to-many sehingga satu order muncul berkali-kali.

Trade-off yang perlu dipahami:

  • Lebih sedikit query belum tentu berarti lebih sedikit data.
  • JOIN pada relasi one-to-many bisa memperbesar jumlah baris hasil.
  • Semakin banyak tabel yang digabungkan, semakin rumit query dan semakin penting evaluasi execution plan di database.

Untuk halaman list, target utamanya adalah mengambil data secukupnya dalam jumlah query yang tetap rendah.

Kapan JOIN Tidak Cukup

Ada kondisi di mana JOIN membantu, tetapi bukan satu-satunya solusi.

1. Relasi one-to-many yang membuat duplikasi baris

Jika satu order punya banyak item, JOIN langsung ke tabel item dapat menggandakan baris order. Untuk kasus ini, Anda mungkin perlu:

  • Mengambil data utama list terlebih dahulu.
  • Mengambil detail terkait dalam satu query terpisah berbasis kumpulan ID.
  • Membentuk hasil di PHP sebagai map, bukan query per baris.

Ini tetap bukan N+1, karena Anda mengganti banyak query kecil menjadi beberapa query terkontrol.

2. Kebutuhan agregasi

Jika halaman butuh jumlah item, total pembayaran, atau status gabungan, sering kali lebih tepat memakai agregasi SQL seperti COUNT atau SUM, subquery, atau tabel materialisasi tergantung kebutuhan.

3. Masalah ada di luar query

Jika jumlah query sudah wajar tetapi halaman tetap lambat, bottleneck bisa berasal dari:

  • Pagination tanpa indeks.
  • View rendering yang berat.
  • Transformasi data di PHP terlalu besar.
  • Query sorting mahal.
  • Koneksi database lambat atau resource server terbatas.

Contoh Refactor Lebih Aman untuk Halaman List

Berikut contoh yang lebih praktis untuk daftar order dengan filter status dan pagination terbatas.

<?php

namespace App\Controllers;

use App\Models\OrderModel;

class Orders extends BaseController
{
    public function index()
    {
        $status = $this->request->getGet('status');
        $limit  = 50;

        $builder = (new OrderModel())
            ->select('orders.id, orders.order_number, orders.status, orders.total, orders.created_at, customers.name AS customer_name')
            ->join('customers', 'customers.id = orders.customer_id', 'left');

        if (! empty($status)) {
            $builder->where('orders.status', $status);
        }

        $orders = $builder
            ->orderBy('orders.created_at', 'DESC')
            ->findAll($limit);

        return view('orders/index', ['orders' => $orders]);
    }
}

Poin penting dari refactor ini:

  • Tidak ada query tambahan di dalam loop.
  • Kolom dipilih secara spesifik.
  • Filter diterapkan langsung di query utama.
  • Data yang dikirim ke view sudah siap pakai.

Cara Membaca Hasil Profiling Setelah Refactor

Setelah pindah ke JOIN, jangan berhenti pada asumsi bahwa performa pasti membaik. Verifikasi dengan data.

Yang perlu dibandingkan

  1. Jumlah query total: harus turun secara jelas.
  2. Total waktu database: idealnya ikut turun atau minimal lebih stabil.
  3. Waktu request end-to-end: cek apakah page render benar-benar lebih cepat.
  4. Konsistensi pada ukuran data berbeda: uji 10, 50, dan 100 item jika relevan.

Tanda refactor berhasil

  • Query berulang per baris hilang.
  • Halaman list tidak lagi melambat drastis saat jumlah item per halaman naik.
  • Beban database lebih terkendali pada traffic yang sama.

Kesalahan Umum Saat Memperbaiki N+1 Query

  • Memindahkan query dari view ke helper tetapi tetap dipanggil di dalam loop.
  • Menggunakan JOIN tanpa alias kolom hingga data bertabrakan, misalnya dua tabel sama-sama punya id atau created_at.
  • Tetap memakai SELECT * setelah JOIN.
  • Menambahkan terlalu banyak JOIN untuk data yang sebenarnya tidak dibutuhkan halaman.
  • Tidak menguji dengan data realistis, sehingga masalah baru muncul setelah deploy.
  • Mengabaikan indeks pada kolom relasi dan filter.

Checklist Verifikasi Performa Setelah Deploy

Setelah perubahan dirilis, gunakan checklist berikut agar perbaikan tidak hanya terasa di lokal, tetapi benar-benar aman di lingkungan nyata.

  1. Pastikan jumlah query per request pada endpoint list turun dibanding versi sebelumnya.
  2. Bandingkan response time endpoint sebelum dan sesudah deploy pada traffic serupa.
  3. Cek apakah ada query lambat baru akibat JOIN yang lebih kompleks.
  4. Verifikasi hasil data tetap benar, terutama pada relasi optional yang memakai LEFT JOIN.
  5. Pastikan pagination, filter, dan sorting tetap bekerja seperti sebelumnya.
  6. Periksa penggunaan indeks pada kolom relasi dan filter yang paling sering dipakai.
  7. Pantau error log untuk nama kolom ambigu, hasil duplikat, atau data hilang.
  8. Jika ada cache di layer aplikasi atau HTTP, pastikan hasil pengukuran tidak bias oleh cache yang belum konsisten.

Penutup

Pada aplikasi CodeIgniter 4, bottleneck N+1 query paling sering muncul di halaman daftar data yang mengambil relasi per baris. Cara paling praktis untuk menanganinya adalah: identifikasi lewat profiler atau query log, hitung jumlah query dan waktu eksekusi, lalu refactor ke JOIN dengan kolom selektif, bentuk data secukupnya, dan pasang indeks dasar pada kolom yang relevan.

Jika setelah itu performa belum cukup baik, evaluasi bentuk relasi, kebutuhan agregasi, dan pola akses datanya. Tujuannya bukan sekadar mengurangi query, tetapi membuat halaman list tetap stabil saat data dan traffic bertambah.