SQL lambat sering bukan berasal dari database yang “buruk”, melainkan dari lapisan abstraksi yang terlalu pintar hingga menghasilkan query yang panjang, penuh subquery, atau sulit diprediksi oleh optimizer. ORM, query builder, dan helper relasi tetap berguna, tetapi ketika detail implementasi disembunyikan terlalu jauh, engineer kehilangan visibilitas atas biaya sebenarnya: scan besar, JOIN tidak selektif, pagination yang makin mahal, dan index yang tidak terpakai.
Belakangan makin banyak engineer skeptis terhadap tool yang menyembunyikan detail penting di balik antarmuka yang terlihat rapi. Konteks ini mirip dengan kekhawatiran umum terhadap komponen “compiler-like” atau vendor binary yang sulit diaudit: masalahnya bukan sekadar soal preferensi, tetapi soal observability dan kendali saat performa memburuk. Dalam konteks database, pelajarannya sederhana: jangan menilai query dari API abstraksinya; nilai dari SQL yang benar-benar dieksekusi dan rencana eksekusinya.
Mengapa abstraksi query bisa menghasilkan SQL lambat
Abstraksi query membantu menyusun kondisi, relasi, sorting, filter dinamis, dan pagination tanpa menulis SQL mentah untuk semua kasus. Masalah muncul ketika lapisan ini mencoba terlalu banyak hal sekaligus:
- menyusun filter dinamis dengan banyak
ORdan kondisi opsional, - menerjemahkan relasi menjadi
JOINatau subquery yang tidak selektif, - menghasilkan
SELECT *meski hanya butuh beberapa kolom, - membungkus query sederhana menjadi beberapa level subquery,
- menggabungkan pagination, sorting, agregasi, dan eager loading dalam satu query besar.
Secara fungsional hasilnya benar. Secara performa, SQL yang dihasilkan bisa jauh dari bentuk yang paling mudah dioptimalkan oleh database.
Gejala yang biasanya terlihat di produksi
- Query membengkak: SQL menjadi sangat panjang, sulit dibaca, dan berubah untuk kombinasi filter yang berbeda.
- Index tidak terpakai: optimizer memilih sequential scan atau full scan karena predicate tidak selektif atau susunan index tidak cocok.
- Pagination makin lambat saat data tumbuh: terutama dengan
OFFSETbesar. - Bottleneck pada JOIN/subquery: relasi yang nyaman di level kode bisa mahal di level SQL.
- N+1 terselubung: helper relasi tampak rapi, tetapi memicu query tambahan per record.
Contoh: SQL buruk vs lebih efisien
Contoh berikut tidak bergantung pada framework tertentu, karena masalahnya umum di banyak ORM dan query builder.
Kasus 1: SELECT * + JOIN berlebihan
Misalnya Anda menampilkan daftar pesanan terbaru untuk satu tenant, hanya butuh beberapa kolom, tetapi abstraksi relasi menghasilkan query seperti ini:
SELECT o.*, c.*, p.*
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
LEFT JOIN payments p ON p.order_id = o.id
WHERE o.tenant_id = 42
AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50;Masalahnya:
SELECT *memindahkan lebih banyak data dari yang dibutuhkan.LEFT JOIN paymentsbisa menduplikasi baris jika satu order punya lebih dari satu payment row.- Urutan sorting dan filtering mungkin sebenarnya hanya bergantung pada tabel
orders.
Versi yang lebih efisien:
SELECT o.id, o.number, o.created_at, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.tenant_id = 42
AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50;Jika status pembayaran memang perlu, sering kali lebih baik diambil dari kolom terdenormalisasi yang valid, atau dimuat dalam query terpisah untuk himpunan ID kecil, tergantung pola akses dan konsistensi data yang dibutuhkan.
Kasus 2: subquery yang terlihat elegan tetapi mahal
Abstraksi helper sering membangun filter relasi sebagai IN (subquery) atau EXISTS. Keduanya bisa valid, tetapi bentuk konkret dan selektivitas data sangat menentukan.
Contoh yang sering muncul:
SELECT id, title, created_at
FROM posts
WHERE tenant_id = 42
AND id IN (
SELECT post_id
FROM comments
WHERE is_spam = false
)
ORDER BY created_at DESC
LIMIT 20;Di sebagian kasus ini baik-baik saja. Namun jika subquery menghasilkan himpunan besar dan tidak didukung index yang tepat, optimizer bisa memilih rencana yang mahal.
Alternatif yang sering lebih mudah dioptimalkan:
SELECT DISTINCT p.id, p.title, p.created_at
FROM posts p
JOIN comments c ON c.post_id = p.id
WHERE p.tenant_id = 42
AND c.is_spam = false
ORDER BY p.created_at DESC
LIMIT 20;Meski begitu, jangan menghafal bahwa JOIN selalu lebih cepat dari subquery. Di beberapa engine dan distribusi data, EXISTS justru lebih baik. Intinya adalah melihat EXPLAIN, bukan menebak dari gaya penulisan saja.
Kasus 3: pagination OFFSET yang makin mahal
Ini masalah klasik yang sering disamarkan oleh helper paginate():
SELECT id, created_at, total
FROM orders
WHERE tenant_id = 42
ORDER BY created_at DESC
LIMIT 50 OFFSET 100000;Semakin besar OFFSET, semakin banyak baris yang harus dilewati lebih dulu. Pada dataset besar, halaman belakang menjadi jauh lebih lambat.
Alternatif yang biasanya lebih stabil adalah keyset pagination atau seek pagination:
SELECT id, created_at, total
FROM orders
WHERE tenant_id = 42
AND (created_at, id) < ('2026-01-10 12:00:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 50;Pendekatan ini membutuhkan cursor dari halaman sebelumnya, tetapi menghindari biaya membuang puluhan atau ratusan ribu baris.
Cara membaca EXPLAIN tanpa tenggelam di detail
Kalau Anda ingin menghindari SQL lambat dari abstraksi query yang terlalu pintar, EXPLAIN adalah alat utama. Tujuannya bukan menjadi pakar internal optimizer, tetapi memahami apakah database membaca data dengan cara yang masuk akal.
Yang perlu diperhatikan lebih dulu
- Access path: apakah query memakai index scan, range scan, atau malah full/sequential scan?
- Estimated rows: apakah jumlah baris yang diperkirakan besar sekali?
- Filter vs index condition: apakah predicate diterapkan sejak awal melalui index, atau baru difilter setelah banyak baris dibaca?
- Sort: apakah database perlu melakukan sort mahal karena index tidak mendukung urutan yang diminta?
- Join order: tabel mana yang dibaca dulu, dan apakah itu masuk akal untuk selektivitas query?
Pola alarm umum di output EXPLAIN
- Scan penuh pada tabel besar padahal query seharusnya selektif.
- Penggunaan temporary sort atau materialization yang besar.
- Nested loop pada himpunan besar tanpa index pendukung di sisi join.
- Rows estimated sangat besar untuk subquery atau relasi yang sebenarnya sempit.
Tips praktis: simpan SQL final dari aplikasi, jalankan
EXPLAINdi database yang representatif, lalu ubah satu variabel setiap kali: bentuk predicate, urutan kolom index, atau cara pagination. Jangan mengubah banyak hal sekaligus jika ingin tahu apa yang benar-benar memperbaiki rencana eksekusi.
Memilih index komposit yang sesuai dengan query nyata
Salah satu kesalahan paling umum adalah membuat index berdasarkan intuisi domain, bukan berdasarkan pola query yang benar-benar dieksekusi. Abstraksi query sering menghasilkan kombinasi WHERE dan ORDER BY tertentu; index harus mengikuti pola itu.
Contoh pemikiran yang benar
Jika query Anda sering seperti ini:
SELECT id, created_at, total
FROM orders
WHERE tenant_id = 42
AND status = 'paid'
ORDER BY created_at DESC
LIMIT 50;Maka index tunggal terpisah pada tenant_id, status, dan created_at belum tentu cukup. Sering kali lebih efektif memakai index komposit yang sesuai dengan pola filter dan sorting, misalnya secara konsep:
(tenant_id, status, created_at)Mengapa? Karena database dapat lebih mudah menemukan subset data yang cocok lalu mempertahankan urutan yang dibutuhkan tanpa sort tambahan, tergantung engine dan bentuk query.
Hal yang sering salah
- Kolom ada di index, tapi urutannya tidak cocok.
- Terlalu banyak index tunggal dengan harapan optimizer akan selalu menggabungkannya secara efisien.
- Index dibuat untuk query ideal, padahal SQL final dari ORM punya predicate tambahan yang mengubah selektivitas.
- Melupakan kolom sort pada query yang sering diurutkan.
Trade-off-nya jelas: semakin banyak dan semakin lebar index, biaya write akan naik dan penggunaan storage bertambah. Karena itu index harus lahir dari query yang benar-benar penting, bukan dari semua kemungkinan filter yang tersedia di UI.
Kapan tetap pakai ORM, kapan turun ke raw SQL
Tujuannya bukan anti-ORM. Untuk operasi CRUD umum, relasi sederhana, dan produktivitas tim, ORM tetap sangat masuk akal. Masalahnya adalah memakai ORM sebagai satu-satunya cara akses data, termasuk untuk query yang jelas-jelas kritis.
ORM cocok untuk
- CRUD standar.
- Relasi yang sederhana dan mudah diprediksi.
- Validasi model dan konsistensi akses data di banyak endpoint.
- Kasus di mana kecepatan pengembangan lebih penting daripada micro-optimization.
Turun ke raw SQL lebih layak ketika
- query masuk jalur panas (hot path) dengan volume tinggi,
- Anda perlu mengontrol kolom yang diambil secara presisi,
- query melibatkan agregasi, CTE, window function, atau pagination khusus,
- hasil SQL dari ORM sulit dibaca atau berubah-ubah,
- optimasi index dan bentuk query harus benar-benar eksplisit.
Pendekatan yang sehat biasanya hibrida: pakai ORM sebagai default, lalu buat escape hatch untuk query penting. Dengan begitu, tim tetap produktif tanpa mengorbankan performa pada endpoint yang sensitif.
Strategi praktis agar abstraksi tidak menyabotase performa
1. Log SQL final, bukan hanya kode builder
Pastikan Anda bisa melihat query yang benar-benar dikirim ke database, termasuk parameter finalnya. Query builder yang tampak sederhana di kode bisa berkembang menjadi SQL yang kompleks setelah semua helper, scope, eager load, dan filter opsional diterapkan.
2. Ukur query pada data yang realistis
Query yang cepat di laptop dengan ribuan baris belum tentu aman di produksi dengan jutaan baris. Gejala seperti index tidak terpakai atau pagination lambat sering baru terlihat setelah distribusi data berubah.
3. Hindari SELECT * secara default
Ambil hanya kolom yang dipakai. Ini mengurangi I/O, memori, biaya serialisasi, dan kadang menghindari join yang sebenarnya tidak perlu.
4. Pisahkan query baca yang kritis dari model domain bila perlu
Tidak semua kebutuhan baca harus mengikuti struktur model relasional yang sama dengan kebutuhan tulis. Untuk endpoint daftar, dashboard, atau laporan, query yang dioptimalkan khusus sering lebih masuk akal daripada memaksa semuanya melalui abstraksi relasi generik.
5. Waspadai helper yang terlihat “gratis”
Filter relasi, eager loading bertingkat, count otomatis, dan pagination total count bisa menambah biaya signifikan. Selalu tanyakan: SQL apa yang dihasilkan? Berapa banyak baris yang dibaca? Apakah query count terpisah juga mahal?
6. Profiling sebelum refactor besar
Jangan langsung menyalahkan ORM. Kadang bottleneck utama justru schema, index yang salah, cardinality yang buruk, atau query count yang meledak dari pola akses aplikasi. Mulailah dari bukti: slow query log, metrics latency, dan EXPLAIN.
Contoh alur diagnosis yang masuk akal
- Temukan endpoint lambat dan ambil SQL finalnya.
- Kelompokkan query berdasarkan frekuensi dan total waktu.
- Jalankan
EXPLAINpada query terburuk. - Periksa apakah masalah utama ada pada scan, sort, join, atau pagination.
- Kurangi kolom, sederhanakan JOIN/subquery, atau ubah strategi pagination.
- Sesuaikan index komposit dengan pola
WHEREdanORDER BY. - Jika ORM terus menghasilkan bentuk SQL yang tidak stabil, pindahkan query itu ke raw SQL yang teruji.
Kesalahan umum yang sering terjadi
- Menganggap query builder yang lebih pendek berarti SQL lebih efisien.
- Memakai
OFFSETbesar untuk semua list API. - Mengandalkan index tunggal terpisah untuk query multi-kondisi.
- Membiarkan helper relasi menghasilkan JOIN yang sebenarnya tidak dibutuhkan.
- Mengoptimalkan tanpa melihat SQL final dan EXPLAIN.
- Berpindah ke raw SQL terlalu cepat tanpa bukti, lalu kehilangan manfaat maintainability.
Penutup
Masalah utama dari abstraksi query yang terlalu pintar bukan karena abstraksi itu salah, melainkan karena ia menyembunyikan biaya. Seperti skeptisisme yang makin kuat terhadap tool yang menutupi detail implementasi penting, backend engineer perlu menjaga jarak sehat: pakai alat yang membantu, tetapi jangan menyerahkan penilaian performa kepadanya.
Praktiknya sederhana: lihat SQL yang sebenarnya, baca EXPLAIN, desain index dari pola query nyata, dan sediakan ruang untuk raw SQL pada jalur kritis. Dengan pendekatan ini, Anda tidak perlu menjadi anti-ORM untuk tetap menjaga sistem cepat saat data dan trafik terus tumbuh.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!