Database Query Performance — Performance

Database Query Performance Performa database sering menjadi bottleneck utama aplikasi web. Query yang lambat bisa membuat halaman yang secepat apapun terasa…

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

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

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

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.

Yang akan kamu pelajari