SQL bottleneck di halaman admin biasanya muncul saat satu endpoint harus melakukan terlalu banyak pekerjaan sekaligus: mengambil daftar data, menghitung total baris, menggabungkan tabel relasi, menerapkan filter, lalu mengurutkan hasil. Pada skala kecil masalah ini sering tidak terlihat, tetapi ketika data tumbuh, query yang tadinya terasa normal mulai memicu latensi tinggi, CPU database naik, dan halaman admin terasa lambat meskipun trafik tidak besar.
Kasus yang paling sering adalah kombinasi COUNT(*) dan join pada tabel besar. Banyak implementasi data table admin menjalankan dua query berat: satu untuk mengambil baris halaman saat ini, satu lagi untuk total data. Jika dua query itu sama-sama menyentuh banyak row, memakai join yang tidak perlu, atau memaksa database melakukan full scan dan filesort, bottleneck akan cepat muncul. Solusinya bukan selalu menambah cache atau upgrade server. Sering kali optimasi SQL yang tepat jauh lebih murah dan lebih stabil.
Gejala SQL bottleneck pada halaman admin
Beberapa gejala yang umum terlihat:
Halaman daftar admin melambat seiring pertumbuhan data, padahal fitur tidak berubah.
Request dengan filter tertentu jauh lebih lambat dibanding filter lain.
Pagination di halaman awal masih cepat, tetapi halaman dengan offset besar mulai berat.
CPU database meningkat saat jam kerja admin, walaupun trafik publik tidak naik.
Query log menunjukkan banyak statement serupa dengan waktu eksekusi tinggi.
EXPLAIN menampilkan type yang buruk, jumlah row besar, atau indikasi Using filesort dan Using temporary.
Pola ini sering muncul pada halaman seperti daftar order, user, invoice, tiket support, atau log aktivitas. Tabel utamanya besar, lalu data ditampilkan bersama relasi seperti nama customer, status, tim, atau kategori.
Kenapa COUNT(*) bisa mahal saat data tumbuh
COUNT(*) terlihat sederhana, tetapi biaya nyatanya tergantung bentuk query dan struktur index. Pada kondisi ideal, database bisa memanfaatkan index yang sesuai sehingga jumlah data yang perlu dibaca lebih kecil. Namun pada banyak kasus halaman admin, query total tidak sesederhana itu karena ikut membawa filter dan join yang sama dengan query daftar.
Masalah yang sering terjadi
COUNT(*) pada hasil join: database harus memproses relasi terlebih dahulu sebelum bisa menghitung hasil akhir.
Filter tidak didukung index: mesin terpaksa membaca banyak row untuk memastikan mana yang cocok.
Query total menyalin query list mentah-mentah: join dan kolom yang sebenarnya hanya dibutuhkan untuk tampilan ikut terbawa ke query count.
COUNT(DISTINCT ...) setelah join: lebih mahal lagi karena ada proses deduplikasi.
Contoh pola yang sering ditemukan:
SELECT COUNT(*)
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'paid'
AND c.deleted_at IS NULL
AND o.created_at >= '2024-01-01';Jika tujuan utama total hanyalah menghitung jumlah order, maka join ke order_items mungkin tidak perlu sama sekali. Semakin banyak relasi yang ikut masuk ke query count, semakin besar peluang database melakukan pekerjaan tambahan yang tidak memberi nilai ke hasil akhir.
Kapan COUNT(*) relatif aman
COUNT(*) masih masuk akal jika:
Tabel utama tidak terlalu besar.
Filter sempit dan didukung index.
Query count hanya menyentuh tabel utama atau join yang benar-benar wajib.
Total data memang dibutuhkan secara akurat, bukan estimasi.
Masalahnya bukan fungsi COUNT(*) itu sendiri, melainkan konteks query di sekitarnya.
Dampak join pada filter, sorting, dan total data
Join sering dibutuhkan untuk menampilkan data yang lebih manusiawi di halaman admin, misalnya menampilkan nama customer alih-alih hanya customer_id. Tetapi join juga memperbesar beban query karena database harus:
Mencocokkan row antar tabel.
Memilih urutan eksekusi join.
Membaca lebih banyak data jika kolom join atau filter tidak terindeks dengan baik.
Melakukan sorting pada hasil gabungan, yang bisa memicu filesort.
Masalah menjadi lebih berat ketika sorting dilakukan pada kolom dari tabel relasi, misalnya:
SELECT o.id, o.created_at, o.total_amount, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
ORDER BY c.name ASC
LIMIT 50 OFFSET 0;Sorting seperti ini tidak selalu bisa memanfaatkan index di tabel utama. Dalam banyak skenario, database harus membentuk hasil join dahulu baru mengurutkannya. Itu berarti I/O dan memori meningkat.
Jika halaman admin butuh filter dari tabel relasi tetapi tidak selalu butuh sorting berdasarkan kolom relasi, prioritaskan sorting pada kolom tabel utama yang bisa didukung index. Ini sering memberi dampak besar pada performa.
Menggunakan EXPLAIN untuk menemukan full scan, filesort, dan index yang tidak terpakai
Sebelum mengubah query atau menambah index, lihat dulu bagaimana database mengeksekusinya. EXPLAIN membantu menjawab pertanyaan penting: tabel mana yang dibaca dulu, index apa yang dipakai, berapa banyak row yang diperkirakan dibaca, dan apakah ada operasi mahal seperti sort tambahan atau temporary table.
Contoh audit dengan EXPLAIN
EXPLAIN
SELECT o.id, o.created_at, o.total_amount, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
AND o.created_at >= '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 50 OFFSET 5000;Hal yang perlu diperiksa:
Apakah tabel utama di-scan terlalu banyak? Jika estimasi row sangat besar, filter mungkin tidak cukup selektif atau index tidak cocok.
Apakah index benar-benar dipakai? Jika tidak, cek urutan kolom index, tipe data, fungsi pada kolom, atau kondisi query.
Apakah muncul filesort? Ini tanda sorting tidak sepenuhnya ditopang index yang sesuai.
Apakah muncul temporary? Umumnya menandakan ada pekerjaan tambahan untuk membentuk hasil antara.
Apakah join order masuk akal? Kadang tabel besar dibaca terlalu awal sehingga kerja berikutnya membengkak.
Penyebab index tidak terpakai
Urutan kolom pada composite index tidak cocok dengan pola
WHEREdanORDER BY.Kolom dibungkus fungsi, misalnya
DATE(created_at), sehingga index sulit dimanfaatkan.Tipe data berbeda saat join atau filter.
Kondisi terlalu longgar sehingga optimizer memilih scan penuh.
Memilih
SELECT *sehingga database harus membaca data tambahan yang tidak perlu.
EXPLAIN tidak otomatis memberi solusi, tetapi sangat efektif untuk mempersempit sumber bottleneck.
Strategi perbaikan yang paling sering efektif
1. Buat composite index sesuai pola WHERE dan ORDER BY
Index tunggal pada setiap kolom belum tentu cukup. Halaman admin biasanya punya pola query berulang, misalnya filter status dan rentang tanggal, lalu sort berdasarkan waktu terbaru. Pada pola seperti ini, composite index sering lebih efektif.
Contoh query:
SELECT o.id, o.created_at, o.total_amount
FROM orders o
WHERE o.status = 'paid'
AND o.created_at >= '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 50;Audit index yang masuk akal untuk dipertimbangkan adalah index yang mengikuti pola filter dan urutan. Misalnya kombinasi kolom status dan created_at pada tabel utama. Prinsipnya:
Mulai dari kolom filter yang paling sering dipakai.
Pertimbangkan selektivitas kolom.
Selaraskan dengan kolom sort jika memang query selalu diurutkan dengan cara yang sama.
Trade-off: semakin banyak index, semakin mahal operasi tulis seperti insert dan update. Jangan menambah index hanya berdasarkan satu query tanpa melihat pola query lain.
2. Hindari SELECT * pada daftar admin
Halaman daftar sering hanya butuh 5-10 kolom, tetapi query mengambil semua kolom dari tabel utama dan tabel relasi. Ini menambah I/O, memperbesar data yang dikirim dari database, dan kadang menghambat penggunaan index yang lebih efisien.
Sebelum:
SELECT *
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50;Sesudah:
SELECT o.id, o.created_at, o.total_amount, o.status, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50;Selain lebih hemat, query seperti ini memaksa kita mendefinisikan kebutuhan data dengan jelas.
3. Pisahkan query total dari query daftar
Kesalahan yang sangat umum adalah menggunakan query yang sama untuk daftar dan total, lalu hanya mengganti bagian SELECT menjadi COUNT(*). Padahal join untuk tampilan belum tentu diperlukan untuk menghitung total.
Sebelum:
SELECT COUNT(*)
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'paid'
AND c.deleted_at IS NULL;Sesudah, jika total hanya menghitung order yang valid:
SELECT COUNT(*)
FROM orders o
WHERE o.status = 'paid'
AND EXISTS (
SELECT 1
FROM customers c
WHERE c.id = o.customer_id
AND c.deleted_at IS NULL
);Atau bila aturan bisnis memungkinkan, query count bisa hanya menyentuh tabel utama:
SELECT COUNT(*)
FROM orders o
WHERE o.status = 'paid';Kenapa ini bekerja? Karena query count seharusnya hanya memuat syarat minimum untuk menghasilkan angka yang benar. Semua kolom dan join yang hanya dipakai untuk rendering tabel sebaiknya tidak ikut dibawa.
4. Gunakan cached counter jika total akurat real-time tidak wajib
Jika halaman admin selalu menampilkan total besar seperti “1.245.932 data”, angka itu mungkin tidak perlu akurat sampai detik terakhir. Dalam kasus seperti ini, cached counter atau materialized summary bisa lebih cocok.
Pilihan umum:
Menyimpan counter di tabel ringkasan yang diperbarui saat data berubah.
Meng-cache hasil count untuk filter yang umum dipakai dengan TTL tertentu.
Menghitung ulang counter secara berkala lewat job terjadwal.
Trade-off penting:
Angka bisa terlambat beberapa detik atau menit.
Logika invalidasi cache bisa rumit.
Counter turunan perlu dijaga saat ada update, delete, restore, atau perubahan status.
Gunakan pendekatan ini jika kebutuhan bisnis menerima eventual consistency. Jika admin butuh angka yang presisi untuk tindakan operasional sensitif, query akurat tetap lebih aman.
5. Hindari offset besar bila perlu
Pagination berbasis LIMIT ... OFFSET ... mudah dipakai, tetapi offset besar makin mahal karena database tetap perlu melewati banyak row sebelum mengembalikan halaman yang diminta.
Contoh yang makin berat saat offset membesar:
SELECT o.id, o.created_at, o.total_amount
FROM orders o
WHERE o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 50 OFFSET 50000;Alternatif yang sering lebih efisien adalah keyset pagination atau seek method:
SELECT o.id, o.created_at, o.total_amount
FROM orders o
WHERE o.status = 'paid'
AND o.created_at < '2024-02-01 10:30:00'
ORDER BY o.created_at DESC
LIMIT 50;Biasanya dipadukan dengan kolom unik tambahan jika nilai sort bisa sama. Kekurangannya, UX nomor halaman absolut menjadi lebih sulit. Namun untuk data sangat besar, pendekatan ini sering jauh lebih stabil.
Contoh perbaikan query sebelum dan sesudah
Kasus awal
Halaman admin menampilkan daftar order dengan filter status, pencarian customer, sort terbaru, dan total data. Query awal:
SELECT o.*, c.*, s.*
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN statuses s ON s.id = o.status_id
WHERE o.status = 'paid'
AND c.name LIKE '%andi%'
ORDER BY o.created_at DESC
LIMIT 50 OFFSET 0;
SELECT COUNT(*)
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN statuses s ON s.id = o.status_id
WHERE o.status = 'paid'
AND c.name LIKE '%andi%';Masalahnya:
SELECT *mengambil terlalu banyak kolom.Query count membawa join yang tidak perlu.
Pencarian
LIKE '%andi%'sulit memakai index biasa secara efektif.Sorting dan filtering belum tentu didukung composite index yang sesuai.
Versi yang lebih efisien
SELECT o.id, o.created_at, o.total_amount, o.status, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
AND c.name LIKE 'andi%'
ORDER BY o.created_at DESC
LIMIT 50;
SELECT COUNT(*)
FROM orders o
WHERE o.status = 'paid'
AND EXISTS (
SELECT 1
FROM customers c
WHERE c.id = o.customer_id
AND c.name LIKE 'andi%'
);Perbaikan yang terjadi:
Kolom yang diambil dipersempit.
Join yang tidak perlu untuk count dihilangkan.
Pola pencarian diubah ke prefix search jika sesuai kebutuhan produk, sehingga peluang pemanfaatan index lebih baik.
Query daftar dan query total dioptimalkan secara terpisah.
Tentu tidak semua aplikasi bisa mengubah pencarian dari %kata% menjadi kata%. Jika pencarian substring wajib, mungkin perlu pendekatan lain seperti full-text search atau mesin pencarian terpisah. Intinya, jangan berharap index biasa menyelamatkan semua bentuk pencarian.
Langkah audit index yang praktis
Sebelum menambah index baru, lakukan audit sederhana berikut:
Kumpulkan query terberat dari slow query log, APM, atau query log aplikasi.
Kelompokkan berdasarkan pola, misalnya daftar order by status + created_at, atau daftar user by team + updated_at.
Lihat index yang sudah ada pada tabel utama dan tabel relasi.
Jalankan EXPLAIN pada query daftar dan query count secara terpisah.
Periksa kolom join apakah kedua sisi memakai tipe data yang konsisten dan sudah terindeks.
Cek urutan filter dan sort untuk menentukan kebutuhan composite index.
Evaluasi index yang jarang terpakai agar tidak menumpuk biaya write tanpa manfaat jelas.
Kesalahan yang sering terjadi adalah membuat banyak index satu per satu tanpa strategi, lalu berharap optimizer otomatis memilih kombinasi terbaik. Dalam praktiknya, desain index yang mengikuti pola query jauh lebih efektif.
Checklist validasi di staging dan produksi
Sebelum deploy ke produksi
Pastikan query baru menghasilkan data yang sama dengan query lama.
Bandingkan EXPLAIN sebelum dan sesudah perubahan.
Uji dengan volume data yang mendekati produksi, bukan hanya data kecil di lokal.
Uji filter yang paling sering dipakai dan filter terburuk yang paling berat.
Uji halaman awal dan halaman dengan offset besar jika masih memakai offset pagination.
Pastikan penambahan index tidak memperlambat proses write penting secara signifikan.
Setelah deploy
Monitor slow query log dan latensi endpoint admin.
Periksa beban CPU, I/O, dan lock pada database.
Bandingkan pola query count dan query list setelah perubahan.
Waspadai regresi pada fitur ekspor, filter kompleks, atau pencarian bebas.
Jika memakai cached counter, verifikasi toleransi selisih data dengan tim produk atau operasional.
Optimasi yang terlihat bagus di staging belum tentu aman di produksi jika distribusi datanya berbeda. Data yang timpang, banyak nilai null, atau filter yang tidak merata bisa mengubah keputusan optimizer.
Kapan optimasi SQL lebih tepat daripada cache atau upgrade server
Cache dan upgrade server punya tempatnya, tetapi keduanya sering menutupi akar masalah jika query dasarnya buruk.
Pilih optimasi SQL lebih dulu jika:
Slow query jelas terlihat pada endpoint admin tertentu.
EXPLAIN menunjukkan full scan, filesort, temporary table, atau index tidak terpakai.
Query count dan query daftar masih membawa join atau kolom yang tidak perlu.
Masalah muncul pada pola query yang berulang dan stabil.
Pertimbangkan cache jika:
Data total tidak harus real-time.
Query sudah cukup sehat tetapi tetap sering dipanggil.
Beban baca jauh lebih besar daripada beban tulis.
Pertimbangkan upgrade server jika:
Query utama sudah masuk akal dan indeks sudah tepat.
Bottleneck memang berasal dari keterbatasan resource, bukan desain query.
Pertumbuhan data dan concurrency sudah melewati kapasitas mesin saat ini.
Urutan keputusan yang sehat biasanya adalah: rapikan query, benahi index, ukur hasilnya, baru pertimbangkan cache atau upgrade infrastruktur bila masih diperlukan.
Kesalahan umum yang perlu dihindari
Menganggap semua query lambat bisa diselesaikan dengan Redis atau cache.
Menambah banyak index tanpa mengukur dampaknya pada write.
Menduplikasi query list menjadi query count tanpa menyederhanakan join.
Memakai
SELECT *di endpoint yang dipanggil sangat sering.Mengabaikan offset besar pada tabel yang terus tumbuh.
Tidak membedakan kebutuhan angka total yang akurat dan yang cukup mendekati.
Penutup
Mengatasi SQL bottleneck dari COUNT(*) dan join di halaman admin hampir selalu dimulai dari observasi yang benar: lihat query nyata, jalankan EXPLAIN, lalu perbaiki hal yang paling mahal lebih dulu. Pada banyak kasus, hasil terbaik datang dari kombinasi sederhana namun disiplin: composite index yang sesuai pola WHERE/ORDER BY, menghindari SELECT *, memisahkan query total dari query daftar, dan mengganti offset besar jika perlu.
Jika sebuah halaman admin tetap lambat saat data tumbuh, jangan langsung berasumsi masalahnya ada pada server yang kurang besar. Sering kali bottleneck utamanya adalah query yang memaksa database bekerja terlalu keras untuk pekerjaan yang sebenarnya bisa dibuat jauh lebih ringan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!