Index with Sort #
Ketika developer menambahkan ORDER BY created_at DESC ke sebuah query, asumsi yang sering muncul adalah “ini pasti cepat karena created_at punya index”. Kenyataannya tidak sesederhana itu. Index yang ada bisa saja tidak dipakai untuk sort, dan database akan melakukan apa yang disebut filesort — mengumpulkan semua baris yang cocok, menyalinnya ke area memory atau disk sementara, lalu mengurutkannya dari awal. Untuk query dengan LIMIT 20, ini berarti database melakukan sorting puluhan ribu baris hanya untuk mengambil 20 teratas. Artikel ini membahas kapan index benar-benar mengeliminasi sort, mengapa urutan dan arah kolom di index sangat penting, bagaimana mendeteksi filesort di EXPLAIN, kasus campuran ASC/DESC yang sering salah ditangani, dan cara merancang index yang membuat sort menjadi gratis.
Apa Itu Filesort dan Mengapa Mahal #
Using filesort di kolom Extra EXPLAIN adalah sinyal bahwa database tidak bisa menggunakan urutan yang sudah ada di index — ia harus menghitung urutan baru dari awal.
-- Query yang memicu filesort
EXPLAIN SELECT id, title, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;
-- Jika index yang ada hanya: INDEX (status)
-- +------+-------+---------------+------+---------+--------------------------+
-- | type | key | possible_keys | rows | filtered | Extra |
-- +------+-------+---------------+------+---------+--------------------------+
-- | ref | idx_ | idx_status | 85000| 100.00 | Using where; Using |
-- | | status| | | | filesort |
-- +------+-------+---------------+------+---------+--------------------------+
-- "Using where" → filter status dipakai
-- "Using filesort" → database mengumpulkan 85.000 baris,
-- lalu mengurutkan semuanya untuk mengambil 20 teratas
-- LIMIT 20 tidak membantu mengurangi biaya sort!
Proses yang terjadi saat filesort:
Proses filesort untuk query di atas:
──────────────────────────────────────────────────────────────
1. Scan index idx_status → temukan 85.000 baris status='published'
2. Baca 85.000 baris dari tabel
3. Salin ke sort buffer (jika cukup) atau temporary file di disk
4. Sort 85.000 baris berdasarkan created_at DESC
5. Ambil 20 baris pertama
6. Kembalikan ke client
Biaya: O(n log n) untuk n = 85.000
Memory: sort_buffer_size (default MySQL: 256KB – 1MB)
Disk: jika data > sort_buffer → tmpfile di disk (sangat lambat)
──────────────────────────────────────────────────────────────
LIMIT 20 hanya menghemat biaya langkah 6.
Langkah 1-5 tetap dijalankan penuh untuk semua 85.000 baris.
Bagaimana Index Mengeliminasi Sort #
Ketika index sudah mencakup kolom sort dengan urutan yang benar, database tidak perlu sort sama sekali — ia cukup scan index dari posisi yang tepat dan data sudah dalam urutan yang diminta.
-- Buat composite index yang mencakup WHERE dan ORDER BY
CREATE INDEX idx_articles_status_created
ON articles (status, created_at DESC);
-- Query yang sama sekarang:
EXPLAIN SELECT id, title, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;
-- +-------+-----------------------------+------+---------+-----------------------+
-- | type | key | rows | filtered | Extra |
-- +-------+-----------------------------+------+---------+-----------------------+
-- | range | idx_articles_status_created | 20 | 100.00 | Using index condition |
-- +-------+-----------------------------+------+---------+-----------------------+
-- rows = 20 ← database hanya membaca 20 baris!
-- Tidak ada "Using filesort"
-- Extra: "Using index condition" → index scan dengan kondisi
Kenapa ini bisa terjadi? Karena B-Tree index (status, created_at DESC) sudah menyimpan data dalam urutan:
Isi index (status, created_at DESC):
──────────────────────────────────────────────────────────────
[published, 2026-04-18 10:30:00] → row ptr
[published, 2026-04-18 09:15:00] → row ptr
[published, 2026-04-17 22:00:00] → row ptr
[published, 2026-04-17 18:45:00] → row ptr
... (sudah terurut by created_at DESC dalam grup status=published)
[draft, 2026-04-18 11:00:00] → row ptr
[draft, ...]
──────────────────────────────────────────────────────────────
Database hanya perlu:
1. Seek ke posisi [published, ...] di B-Tree
2. Scan 20 entri ke bawah
3. Selesai — tidak ada sort runtime
Aturan Fundamental: Equality Sebelum Sort #
Aturan yang paling sering dilanggar saat mendesain index untuk sort adalah menempatkan kolom sort di posisi yang salah dalam composite index.
-- Query: artikel published dari kategori tertentu, diurutkan terbaru
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
AND category_id = 5
ORDER BY created_at DESC
LIMIT 20;
-- ANTI-PATTERN 1: sort column di depan equality column
CREATE INDEX idx_wrong_1 ON articles (created_at DESC, status, category_id);
-- → Database tidak bisa pakai index untuk filter status dan category_id
-- karena mereka bukan prefix dari index
-- → Filesort muncul
-- ANTI-PATTERN 2: sort column di tengah equality columns
CREATE INDEX idx_wrong_2 ON articles (status, created_at DESC, category_id);
-- → Database bisa pakai status, tapi setelah bertemu created_at (sort column)
-- ia tidak bisa lanjut ke category_id di posisi berikutnya
-- → category_id tidak bisa dipakai lewat index ini
-- → Filesort bisa muncul
-- BENAR: semua equality column dulu, sort column terakhir
CREATE INDEX idx_correct ON articles (status, category_id, created_at DESC);
-- → Database: seek ke (status='published', category_id=5)
-- → Dalam posisi itu, baris sudah terurut by created_at DESC
-- → Ambil 20, selesai — tanpa filesort
Aturannya:
Urutan yang benar dalam composite index untuk WHERE + ORDER BY:
──────────────────────────────────────────────────────────────
1. Kolom equality (=) dengan selectivity tertinggi
2. Kolom equality (=) berikutnya
3. Kolom range (>, <, BETWEEN) jika ada
4. Kolom ORDER BY — harus di posisi paling akhir
Catatan: setelah kolom range, kolom ORDER BY
mungkin tidak bisa dipakai untuk menghindari sort.
──────────────────────────────────────────────────────────────
Interaksi Range dengan Sort #
Ini adalah kasus yang paling sering disalahpahami: ketika ada kondisi range (>, <, BETWEEN) sebelum kolom sort, index tidak bisa mengeliminasi sort.
-- Query: artikel yang dibuat dalam 7 hari terakhir, diurutkan terbaru
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
AND created_at >= NOW() - INTERVAL 7 DAY -- ← range condition
ORDER BY created_at DESC
LIMIT 20;
-- Index: (status, created_at DESC)
-- Apa yang terjadi?
-- Database seek ke status='published', created_at >= [threshold]
-- Dalam rentang tersebut, data SUDAH terurut by created_at DESC
-- → Tidak ada filesort! Range pada kolom sort yang sama tidak bermasalah.
-- Tapi jika range bukan pada kolom sort:
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
AND price BETWEEN 100000 AND 500000 -- ← range pada kolom LAIN
ORDER BY created_at DESC
LIMIT 20;
-- Index ideal: (status, price, created_at DESC)
-- Masalah: setelah menemukan baris dalam range price,
-- baris tersebut TIDAK lagi terurut by created_at
-- Database masih harus sort → filesort muncul
-- Ini adalah batasan fundamental B-Tree index
Mixed Sort Direction: ASC dan DESC Bersamaan #
Kasus yang paling sering salah ditangani adalah ketika query butuh sort arah berbeda untuk kolom yang berbeda — misalnya ORDER BY price ASC, created_at DESC.
Di MySQL #
MySQL sebelum 8.0 tidak mendukung descending index — semua kolom di index disimpan ASC. MySQL 8.0+ mendukung:
-- MySQL 8.0+: descending index per kolom
CREATE INDEX idx_articles_price_created
ON articles (price ASC, created_at DESC);
-- Query yang memanfaatkan ini:
SELECT id, title, price, created_at
FROM articles
WHERE status = 'published'
ORDER BY price ASC, created_at DESC
LIMIT 20;
-- EXPLAIN di MySQL 8.0+:
-- Extra: "Using index condition" ← tidak ada filesort!
-- Di MySQL 5.7 atau lebih lama:
-- DESC di index diabaikan, semua disimpan ASC
-- Query ORDER BY price ASC, created_at DESC akan memicu filesort
-- karena index tidak menyimpan created_at dalam urutan DESC
Cara mendeteksi apakah MySQL menggunakan descending index:
-- Cek apakah index benar-benar disimpan sebagai DESC
SHOW CREATE TABLE articles;
-- Lihat apakah ada DESC di definisi index:
-- KEY `idx_articles_price_created` (`price`,`created_at` DESC)
Di PostgreSQL #
PostgreSQL lebih fleksibel — mendukung mixed sort direction dari awal:
-- PostgreSQL: bisa tentukan arah per kolom
CREATE INDEX idx_articles_price_created
ON articles (price ASC NULLS LAST, created_at DESC NULLS FIRST);
-- NULLS FIRST / NULLS LAST menentukan posisi nilai NULL dalam sort
-- Penting untuk mencegah "mismatch sort" yang tetap memicu sort runtime
-- Query:
SELECT id, title, price, created_at
FROM articles
WHERE status = 'published'
ORDER BY price ASC NULLS LAST, created_at DESC NULLS FIRST
LIMIT 20;
-- EXPLAIN ANALYZE:
-- -> Index Scan using idx_articles_price_created on articles
-- (cost=0.43..95.23 rows=20 width=44) (actual time=0.028..0.089 rows=20 loops=1)
-- Tidak ada Sort node → index scan langsung menghasilkan urutan yang benar
Backward Index Scan #
Ketika query meminta sort berlawanan dengan arah yang disimpan index, database bisa melakukan backward scan — membaca index dari belakang ke depan. Ini hampir seefisien forward scan untuk kebanyakan kasus.
-- Index: (status, created_at) — implisit ASC
CREATE INDEX idx_articles_status_created ON articles (status, created_at);
-- Query ASC: forward scan
SELECT id FROM articles
WHERE status = 'published'
ORDER BY created_at ASC
LIMIT 20;
-- → Scan index dari depan: efisien
-- Query DESC: backward scan
SELECT id FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;
-- → Scan index dari belakang: hampir sama efisiennya
-- EXPLAIN Extra: "Backward index scan" (MySQL 8.0+)
-- atau "Index Scan Backward" (PostgreSQL)
-- Tidak ada filesort!
Backward scan punya sedikit overhead dibanding forward scan karena navigasi B-Tree berbeda, tapi perbedaannya jauh lebih kecil dibanding filesort. Untuk sebagian besar kasus, satu index untuk kedua arah (ASC dan DESC) sudah cukup tanpa perlu membuat dua index terpisah.
Kapan perlu dua index terpisah (ASC dan DESC)?
──────────────────────────────────────────────────────────────
Tidak perlu dua index jika:
✓ Hanya satu kolom yang di-sort
✓ Query selalu pakai satu arah saja
✓ Mixed direction tidak ada dalam query
Pertimbangkan dua index atau descending index jika:
✗ Query butuh mixed direction (price ASC, created_at DESC)
✗ Backward scan terbukti lambat di query yang sangat sering
(jarang terjadi, verifikasi dengan benchmark)
──────────────────────────────────────────────────────────────
Covering Index untuk Sort: Eliminasi Table Lookup #
Ketika semua kolom yang dibutuhkan query (SELECT, WHERE, ORDER BY) ada di dalam index, database tidak perlu membaca baris dari tabel sama sekali — ini disebut index-only scan atau covering index.
-- Query yang sering dijalankan: list artikel terbaru dengan data minimal
SELECT id, title, status, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;
-- Index biasa: (status, created_at DESC)
-- Database: pakai index untuk seek dan sort
-- Tapi masih perlu baca tabel untuk kolom 'title' dan 'id'
-- Extra: "Using index condition" (masih ada table lookup)
-- Covering index: tambahkan semua kolom SELECT
CREATE INDEX idx_articles_covering
ON articles (status, created_at DESC, title, id);
-- Atau lebih ringkas karena 'id' adalah primary key yang selalu ada di index:
CREATE INDEX idx_articles_covering
ON articles (status, created_at DESC, title);
-- Sekarang:
-- Extra: "Using index" ← index-only scan, tidak ada table lookup
-- Rows dibaca: tepat 20 — tidak lebih
-- Perbandingan performa:
-- Tanpa covering: baca 20 entri index + 20 lookup ke tabel
-- Dengan covering: baca 20 entri index saja — 2× lebih cepat untuk heavy read
Covering index paling bermanfaat untuk endpoint yang sering dipanggil dengan hasil yang sama berulang kali — misalnya halaman listing produk atau feed artikel. Tambahkan kolom ke covering index hanya jika kolom tersebut kecil (integer, timestamp, string pendek). Jangan masukkan kolom TEXT atau JSON ke covering index karena akan membuat index sangat besar dan memperlambat write.
Kasus Nyata yang Sering Salah Didesain #
Kasus 1: Feed Aktivitas User #
-- Query: 20 aktivitas terbaru dari satu user
SELECT id, type, message, created_at
FROM activity_logs
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
-- ANTI-PATTERN: index hanya di user_id
CREATE INDEX idx_logs_user ON activity_logs (user_id);
-- → Filter user_id: ✓ (mungkin 50.000 log untuk user ini)
-- → Sort created_at: ✗ (filesort 50.000 baris)
-- BENAR: composite index user_id + created_at
CREATE INDEX idx_logs_user_created ON activity_logs (user_id, created_at DESC);
-- → Seek ke user_id=42, baris sudah terurut by created_at DESC
-- → Ambil 20, selesai — zero filesort
-- → rows di EXPLAIN: 20 (bukan 50.000)
Kasus 2: Dashboard dengan Multiple Filter #
-- Query: order terbaru dengan filter status dan tenant
SELECT id, order_number, total, created_at
FROM orders
WHERE tenant_id = 'abc123'
AND status = 'pending'
ORDER BY created_at DESC
LIMIT 20;
-- Index: (tenant_id, status, created_at DESC)
-- ✓ Equality: tenant_id
-- ✓ Equality: status
-- ✓ Sort: created_at DESC — sudah di posisi terakhir, tepat
-- Verifikasi dengan EXPLAIN:
EXPLAIN SELECT id, order_number, total, created_at
FROM orders
WHERE tenant_id = 'abc123' AND status = 'pending'
ORDER BY created_at DESC LIMIT 20;
-- Yang diharapkan:
-- type: range atau ref
-- key: idx_orders_tenant_status_created
-- rows: ≈ 20 (bukan ribuan)
-- Extra: "Using index condition" (tidak ada "Using filesort")
Kasus 3: Sort dengan Nullable Column #
-- Query: produk aktif diurutkan berdasarkan featured_at (bisa NULL)
-- featured_at NULL berarti produk tidak di-feature
SELECT id, name, featured_at
FROM products
WHERE status = 'active'
ORDER BY featured_at DESC -- NULL akan di-bawah
LIMIT 20;
-- Masalah: NULLS dalam sort bisa menyebabkan sort runtime
-- Di MySQL: NULL dianggap paling kecil (muncul terakhir di DESC)
-- Di PostgreSQL: bisa dikontrol dengan NULLS FIRST / NULLS LAST
-- Index yang tepat di MySQL:
CREATE INDEX idx_products_status_featured
ON products (status, featured_at DESC);
-- NULL akan ada di bagian akhir → produk featured muncul dulu
-- Di PostgreSQL dengan NULLS LAST:
CREATE INDEX idx_products_status_featured
ON products (status, featured_at DESC NULLS LAST);
-- Pastikan query menggunakan ORDER BY featured_at DESC NULLS LAST
-- agar sesuai dengan index dan tidak memicu sort runtime
Mendeteksi dan Memperbaiki Filesort di Production #
Cara Menemukan Query dengan Filesort #
-- MySQL: aktifkan slow query log dengan minimal data
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1; -- log query > 100ms
SET GLOBAL log_queries_not_using_indexes = 'ON';
-- Kemudian analisis dengan:
-- mysqldumpslow -s t /var/log/mysql/slow.log
-- Cari query dengan "filesort" di EXPLAIN-nya
-- PostgreSQL: pg_stat_statements untuk query terlambat
SELECT query,
calls,
total_exec_time / calls AS avg_ms,
rows / calls AS avg_rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- Jalankan EXPLAIN ANALYZE untuk setiap query yang curigai
Membaca EXPLAIN: Sinyal Filesort #
-- Sinyal masalah di EXPLAIN MySQL:
-- Extra: "Using filesort" → sort runtime, bukan index sort
-- Extra: "Using temporary" → sort butuh tabel sementara (lebih mahal)
-- rows: angka besar dengan LIMIT kecil → scan terlalu banyak baris
-- Sinyal masalah di EXPLAIN PostgreSQL:
-- -> Sort (cost=...) → ada Sort node → filesort
-- -> Seq Scan → tidak pakai index sama sekali
-- Target yang baik di EXPLAIN:
-- MySQL: Extra mengandung "Using index" atau "Using index condition",
-- TIDAK mengandung "Using filesort"
-- PostgreSQL: tidak ada Sort node di atas Index Scan
Anti-Pattern yang Harus Dihindari #
-- ✗ Anti-pattern 1: index hanya untuk filter, lupa sort
CREATE INDEX idx_orders_status ON orders (status);
-- Query: WHERE status = ? ORDER BY created_at DESC LIMIT 20
-- → Filter pakai index, tapi sort masih filesort
-- ✓ Solusi: CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC)
-- ✗ Anti-pattern 2: sort column di depan equality column
CREATE INDEX idx_orders_created_status ON orders (created_at DESC, status);
-- Query: WHERE status = 'paid' ORDER BY created_at DESC
-- → Index tidak optimal karena status bukan prefix
-- ✓ Solusi: (status, created_at DESC) — equality dulu
-- ✗ Anti-pattern 3: mixed ASC/DESC tanpa dukungan database
-- MySQL 5.7: ORDER BY price ASC, created_at DESC
CREATE INDEX idx_price_created ON products (price, created_at);
-- → created_at di index disimpan ASC, tapi query minta DESC
-- → Filesort muncul meski index ada
-- ✓ Solusi: upgrade ke MySQL 8.0+ dan pakai (price ASC, created_at DESC)
-- ✗ Anti-pattern 4: fungsi di ORDER BY
ORDER BY DATE(created_at) DESC -- fungsi mematikan index sort
-- ✓ Solusi: ORDER BY created_at DESC (gunakan range WHERE jika perlu filter tanggal)
-- ✗ Anti-pattern 5: sort tanpa LIMIT di endpoint publik
SELECT * FROM products WHERE status = 'active' ORDER BY created_at DESC;
-- → Filesort seluruh dataset untuk dikembalikan semua
-- → Tanpa LIMIT: database sort ribuan/jutaan baris
-- ✓ Solusi: selalu tambahkan LIMIT, gunakan pagination
Checklist Index untuk Sort #
SAAT MENDESAIN INDEX BARU UNTUK QUERY DENGAN ORDER BY:
□ Kolom equality ada di posisi sebelum kolom sort di index?
□ Arah sort (ASC/DESC) di index sesuai dengan query?
Di MySQL 8+: bisa specify DESC per kolom
Di MySQL 5.7: semua ASC, gunakan backward scan untuk DESC
Di PostgreSQL: bisa specify per kolom + NULLS FIRST/LAST
□ Ada kolom range antara equality dan sort? Jika ya:
→ Sort kemungkinan tidak bisa dihindari — pertimbangkan strategi lain
□ Sudah diverifikasi dengan EXPLAIN bahwa "Using filesort" tidak muncul?
□ Rows di EXPLAIN mendekati nilai LIMIT (bukan ribuan untuk LIMIT 20)?
UNTUK COVERING INDEX:
□ Semua kolom di SELECT ada di index (termasuk kolom sort)?
□ Tidak ada kolom TEXT/BLOB/JSON besar di covering index?
□ EXPLAIN menampilkan "Using index" (bukan "Using index condition")?
UNTUK QUERY DENGAN MIXED SORT:
□ Sudah cek apakah MySQL versi yang dipakai mendukung descending index?
□ Di PostgreSQL: NULLS FIRST/LAST konsisten antara index dan query?
□ Sudah benchmark apakah filesort di sini benar-benar masalah performa?
Ringkasan #
Using filesortdi EXPLAIN berarti sort berjalan di runtime — database mengumpulkan semua baris yang cocok, mengurutkannya, baru mengambil LIMIT. Untuk 85.000 baris dengan LIMIT 20, ini tetap sort 85.000 baris penuh.- LIMIT tidak menyelamatkan query tanpa index sort — LIMIT hanya menghemat pengambilan baris setelah sort selesai. Operasi sort itu sendiri tetap memproses semua baris yang cocok dengan WHERE.
- Equality columns harus di depan sort column di index — urutan
(status, created_at DESC)optimal untukWHERE status = ? ORDER BY created_at DESC. Urutan(created_at DESC, status)tidak optimal.- Range condition sebelum sort column mempersulit eliminasi filesort — jika ada
BETWEENatau>pada kolom berbeda dari kolom sort, index biasanya tidak bisa mengeliminasi sort sepenuhnya.- Backward scan hampir seefisien forward scan —
INDEX (created_at)bisa dipakai untukORDER BY created_at ASCmaupunORDER BY created_at DESC. Tidak perlu dua index terpisah untuk dua arah sort.- Mixed ASC/DESC membutuhkan descending index —
ORDER BY price ASC, created_at DESCmembutuhkanINDEX (price ASC, created_at DESC). Di MySQL 5.7 ini tidak bisa dilakukan dan akan selalu filesort; butuh MySQL 8.0+.- Covering index menghilangkan table lookup — jika semua kolom SELECT ada di index, EXPLAIN menampilkan “Using index” dan database tidak pernah menyentuh tabel utama. Sangat efektif untuk endpoint yang sering dipanggil.
- Selalu verifikasi dengan EXPLAIN setelah menambah index — desain yang terlihat benar di atas kertas bisa saja tidak dipakai oleh query planner. EXPLAIN adalah satu-satunya cara membuktikan bahwa index benar-benar mengeliminasi filesort.