Database sering jadi bagian paling lambat dari sebuah request: selama query belum selesai, server belum bisa mengirim apa pun, jadi halaman secepat apa pun ikut menunggu. Bayangin perpustakaan. Kamu minta daftar 20 buku terbaru, lalu bolak-balik ke meja petugas 20 kali untuk menanyakan penulis tiap buku, satu per satu. Jauh lebih cepat kalau sekali tanya: "sekalian penulis dari 20 buku ini, ya." Dan petugas yang punya katalog berurut gak perlu menyisir semua rak.
🧪 Tebakan cepat: halaman menampilkan 20 post terbaru beserta nama penulisnya. Kodenya mengambil 20 post, lalu di dalam loop membaca
$post->author->name. Berapa query yang jalan: 1, 2, atau 21? Tebak dulu, jawabannya ada di bagian "N+1 query".
Istilah dulu
- Query: satu perintah SQL ke database, misalnya
SELECT ... FROM posts. Tiap query punya biaya tetap: bolak-balik ke server database, sekitar 1 ms di data center yang sama, ditambah waktu mengerjakannya. - ORM: library yang menerjemahkan kode jadi SQL, misalnya Eloquent di Laravel atau Prisma di Node.js. Enak dipakai, tapi gampang menyembunyikan query.
- Index: struktur tambahan yang membuat database bisa langsung ke baris yang dicari.
- Eager loading: memuat relasi (misalnya author tiap post) sekaligus di awal, bukan satu per satu saat dipakai.
N+1 query
Ini log query dari Lab di halaman ini: tabel posts berisi 50.000 baris, dan halaman menampilkan 20 post.
select * from posts order by created_at desc limit 20; -- query 1
select * from users where id = 7; -- query 2, untuk post pertama
select * from users where id = 3; -- query 3, untuk post kedua
-- ... 18 query lagi, satu untuk tiap post
Satu query untuk daftar post, lalu N query lagi untuk author tiap post: total 20 + 1 = 21 query. Itu jawaban tebakannya. Namanya N+1. Dengan 100 post, jadi 101 query.
// N+1: 21 query
$posts = Post::latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->author->name; // query baru di setiap putaran
}
// Eager loading: 2 query
$posts = Post::with('author')->latest()->take(20)->get();
with('author') mengganti 20 query kecil dengan satu: select * from users where id in (7, 3, 12, ...). Hasilnya selalu 2 query, mau 20 atau 100 post.
| Post di halaman | Tanpa eager loading | Dengan with('author') |
|---|---|---|
| 20 | 21 query | 2 query |
| 50 | 51 query | 2 query |
| 100 | 101 query | 2 query |
Di Prisma namanya include: { author: true }. Untuk menangkapnya, pakai Laravel Debugbar atau Telescope, atau Model::preventLazyLoading() yang melempar error setiap ada relasi yang dimuat diam-diam di dalam loop.
Index: daftar isi untuk tabel
Tanpa index, database membaca semua baris untuk menemukan yang cocok (full table scan). Index jenis yang paling umum, B-tree, menyimpan nilai kolom dalam urutan, seperti katalog perpustakaan, jadi database bisa langsung lompat ke tempat yang tepat. Idenya mirip binary search.
CREATE INDEX idx_posts_created_at ON posts (created_at);
Di Lab, query pertama (order by created_at desc limit 20) tanpa index harus membaca 50.000 baris lalu mengurutkannya, sekitar 45 ms. Dengan index, database membaca 20 baris teratas langsung dari index, sekitar 1 ms.
Kolom yang paling layak diberi index: yang sering dipakai di WHERE, JOIN, dan ORDER BY. Jangan index semua kolom: setiap index ikut diperbarui di setiap INSERT, UPDATE, dan DELETE, dan makan ruang disk.
EXPLAIN: tanya rencana database
EXPLAIN menunjukkan bagaimana database akan menjalankan query, tanpa menjalankannya. EXPLAIN ANALYZE benar-benar menjalankannya dan menunjukkan waktu nyata (PostgreSQL, dan MySQL 8 juga punya).
EXPLAIN SELECT id, title FROM posts ORDER BY created_at DESC LIMIT 20;
| Di MySQL | type | key | rows | Extra |
|---|---|---|---|---|
| Tanpa index | ALL |
NULL |
49.870 | Using filesort |
| Dengan index | index |
idx_posts_created_at |
20 | Backward index scan |
type: ALL dan key: NULL artinya full scan; rows adalah perkiraan baris yang dibaca. Di PostgreSQL, cari Seq Scan (membaca semua) lawan Index Scan.
Query yang bikin index gak terpakai
-- BURUK: semua kolom ikut terbawa, termasuk yang gak dipakai
SELECT * FROM users;
-- BAIK
SELECT id, name, email FROM users;
-- BURUK: fungsi di kolom, index created_at gak bisa dipakai
SELECT id FROM orders WHERE YEAR(created_at) = 2025;
-- BAIK: rentang yang bisa memakai index
SELECT id FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';
-- BURUK: wildcard di depan, B-tree gak bisa dipakai
SELECT id FROM products WHERE name LIKE '%laptop%';
-- BAIK: full text search
SELECT id FROM products WHERE MATCH(name) AGAINST('laptop');
LIKE 'laptop%' (wildcard di belakang) masih bisa memakai index, karena awal teksnya diketahui.
Pagination dan cache untuk bacaan yang sering
- OFFSET membaca lalu membuang:
LIMIT 20 OFFSET 100000membaca 100.020 baris untuk mengirim 20, jadi halaman yang dalam makin lambat. Cursor pagination melanjutkan dari baris terakhir:WHERE created_at < '2025-06-01 10:00:00' ORDER BY created_at DESC LIMIT 20langsung lompat lewat index. Di Laravel:cursorPaginate(20). Gantinya, kamu gak bisa lompat langsung ke halaman 500. - Cache untuk bacaan yang sering: daftar post di beranda dibaca ribuan kali tapi jarang berubah.
Cache::remember('beranda.posts', 60, fn () => Post::with('author')->latest()->take(20)->get())menjalankan query sekali tiap 60 detik, bukan sekali tiap kunjungan.
Versi latihan: analyzeQuery dan estimateQueryCost
analyzeQuery mencari dua pola di teks SQL, dan mengisi issues serta suggestions dengan teks persis ini, berurutan: SELECT * dulu, lalu LIKE.
| Query | issues |
suggestions |
|---|---|---|
SELECT * FROM users |
SELECT * mengambil semua kolom |
Pilih kolom spesifik yang dibutuhkan |
SELECT id, name FROM users |
kosong | kosong |
SELECT * FROM products WHERE name LIKE '%laptop%' |
SELECT * mengambil semua kolom, lalu LIKE dengan wildcard di awal |
Pilih kolom spesifik yang dibutuhkan, lalu Gunakan Full Text Search |
estimateQueryCost({ rows, joins, indexes }) adalah model mainan, bukan hitungan database sungguhan: mulai dari rows, kali 1,5 untuk setiap join, bagi 10 sekali kalau ada index (indexes > 0, berapa pun jumlahnya), lalu Math.round.
| Plan | Hitungan | Hasil |
|---|---|---|
{ rows: 1000, joins: 2, indexes: 1 } |
1000 × 1,5 × 1,5 ÷ 10 | 225 |
{ rows: 100, joins: 0, indexes: 0 } |
100 | 100 |
{ rows: 500, joins: 1, indexes: 2 } |
500 × 1,5 ÷ 10 | 75 |
⚠️ Jebakan yang sering ketemu
- N+1 yang tersembunyi: di dalam template, accessor, atau API resource. Kodenya terlihat bersih, log query-nya penuh. Nyalakan
preventLazyLoading()di development. - Index di semua kolom. Baca jadi cepat, tulis jadi lambat. Index yang dipakai WHERE, JOIN, dan ORDER BY saja, lalu cek dengan EXPLAIN.
- Fungsi pada kolom atau
LIKE '%...'. Index-nya ada, tapi gak terpakai. - Di latihan: teks issue dan suggestion harus persis sama (huruf besar kecilnya juga), urutannya SELECT * dulu lalu LIKE, dan pembagian 10 cuma sekali walaupun
indexes2.
TL;DR: Tiap query punya biaya bolak-balik, jadi N+1 (1 query daftar + 1 query per baris) cepat menumpuk: 20 post jadi 21 query. Eager loading (with('author') di Laravel) membuatnya selalu 2 query. Index B-tree seperti katalog berurut: kolom di WHERE, JOIN, dan ORDER BY dibaca langsung, bukan full scan, tapi setiap index memperlambat tulis. Cek dengan EXPLAIN (type: ALL, key: NULL berarti full scan) atau EXPLAIN ANALYZE untuk waktu nyata. Pilih kolom yang perlu saja, hindari fungsi di kolom dan LIKE '%...', pakai cursor pagination untuk halaman dalam, dan cache bacaan yang sering.