RAND() #

ORDER BY RAND() LIMIT 10 adalah salah satu query yang paling sering ditulis developer saat membutuhkan data acak — dan juga salah satu yang paling sering menjadi penyebab masalah performa di production. Query ini terlihat sederhana, langsung bisa dipakai, dan menghasilkan output yang benar. Masalahnya tersembunyi di cara database mengeksekusinya: untuk mendapatkan 10 baris acak, database harus membaca, mengacak, dan mengurutkan seluruh tabel terlebih dahulu. Di tabel dengan jutaan baris, satu query ini bisa menguras CPU database dan menyebabkan efek domino ke seluruh sistem. Artikel ini membahas mengapa ini terjadi, empat alternatif yang jauh lebih efisien, dan bagaimana menangani pola serupa seperti ORDER BY COUNT yang punya akar masalah yang sama.

Cara Kerja Internal ORDER BY RAND() #

Untuk memahami mengapa ORDER BY RAND() mahal, perlu dipahami dulu apa yang sebenarnya dilakukan database saat mengeksekusi query ini. Bukan hanya “mengambil baris secara acak” — prosesnya jauh lebih berat dari itu.

Langkah Eksekusi yang Tersembunyi #

Proses eksekusi ORDER BY RAND() LIMIT 10:
──────────────────────────────────────────────────────────────
  Tabel: articles (1.000.000 baris)

  Langkah 1: Full Table Scan
    → Baca SELURUH 1 juta baris dari disk ke memory
    → Tidak ada index yang bisa membantu — RAND() tidak deterministik

  Langkah 2: Generate Nilai Random per Baris
    → Panggil RAND() sebanyak 1 juta kali
    → Setiap baris mendapat nilai float antara 0 dan 1
    → Contoh: baris 1 = 0.7234, baris 2 = 0.1892, dst.

  Langkah 3: Full Sort
    → Urutkan 1 juta baris berdasarkan nilai random tadi
    → Algoritma sort: O(n log n)
    → Jika data tidak muat di memory → spill ke disk (filesort)

  Langkah 4: Ambil 10 Baris Teratas
    → Setelah sort selesai, buang 999.990 baris
    → Kembalikan 10 baris

  Total: baca 1 juta baris, sort 1 juta baris, buang 999.990 baris.
  Hanya untuk mendapatkan 10 baris.
──────────────────────────────────────────────────────────────

Mengapa Index Tidak Bisa Membantu #

Index bekerja karena nilainya deterministik dan tersimpan — database tahu nilai email kolom tertentu adalah [email protected], dan nilai itu tidak berubah setiap query dijalankan. Index bisa digunakan untuk langsung melompat ke baris yang tepat.

RAND() menghasilkan nilai yang berbeda setiap kali dipanggil, per baris, per eksekusi. Tidak ada cara menyimpan nilai random di index karena nilainya tidak ada sampai query dijalankan. Akibatnya:

-- EXPLAIN untuk ORDER BY RAND()
EXPLAIN SELECT * FROM articles ORDER BY RAND() LIMIT 10;

-- Output:
-- +------+-------------+----------+------+------+-----------------------------+
-- | type | key         | rows     | ref  | Extra                        |
-- +------+-------------+----------+------+------+-----------------------------+
-- | ALL  | NULL        | 1000000  | NULL | Using temporary; Using filesort |
-- +------+-------------+----------+------+------+-----------------------------+

-- type = ALL       → full table scan
-- key = NULL       → tidak ada index yang dipakai
-- Using temporary  → MySQL membuat tabel sementara
-- Using filesort   → sort dilakukan di luar index

Dua tanda merah sekaligus: Using temporary dan Using filesort. Ini adalah kombinasi termahal yang bisa muncul di kolom Extra.

Dampak di Production #

Dampak ORDER BY RAND() di sistem live bukan sekadar query yang lambat — ia menciptakan efek domino:

Efek domino ORDER BY RAND() di production:
──────────────────────────────────────────────────────────────
  Query dieksekusi
      │
      ▼
  Database baca 1 juta baris → I/O disk tinggi
      │
      ▼
  Sort di memory → memory database terpakai besar
      │
      ├─ Jika memory tidak cukup → spill ke disk
      │       → I/O disk makin tinggi
      │       → Latency melonjak
      │
      ▼
  CPU spike (sort adalah operasi CPU-intensive)
      │
      ▼
  Query lain ikut antri → latency seluruh sistem naik
      │
      ▼
  Connection pool penuh → request timeout
      │
      ▼
  Error 500 di sisi user
──────────────────────────────────────────────────────────────
  Satu query "kecil" bisa membawa seluruh sistem ke kondisi ini
  jika dieksekusi oleh banyak concurrent user sekaligus.

Kasus ini sangat umum: tim melihat CPU database 95%, semua orang panik mencari penyebabnya, dan setelah beberapa jam investigasi ditemukan satu endpoint yang memanggil ORDER BY RAND() — dan endpoint itu dipanggil setiap kali halaman utama dimuat.


Empat Alternatif yang Scalable #

Setiap alternatif di bawah punya trade-off yang berbeda. Pilih berdasarkan kebutuhan: apakah randomness harus sempurna? Apakah data sangat besar? Apakah boleh ada sedikit bias dalam distribusi?

Alternatif 1: ID Sampling dengan MAX(id) #

Pendekatan ini memanfaatkan fakta bahwa primary key integer ter-index, sehingga database bisa melompat langsung ke baris tertentu tanpa scan.

-- ANTI-PATTERN: ORDER BY RAND() yang mahal
SELECT * FROM articles ORDER BY RAND() LIMIT 1;
-- → Full scan 1 juta baris, sort 1 juta baris

-- BENAR: ID sampling dengan MAX(id)
SELECT *
FROM articles
WHERE id >= FLOOR(RAND() * (SELECT MAX(id) FROM articles))
ORDER BY id
LIMIT 1;
-- → Subquery: 1 lookup untuk MAX(id) via index
-- → WHERE id >= X: range scan mulai dari X, ambil 1 baris
-- → Tanpa sort, tanpa full scan

Untuk mengambil beberapa baris sekaligus dengan distribusi yang lebih merata:

-- Mengambil 10 baris acak dengan beberapa anchor point
SELECT *
FROM articles
WHERE id >= FLOOR(RAND() * (SELECT MAX(id) FROM articles))
ORDER BY id
LIMIT 10;

-- Catatan: ini mengambil 10 baris BERURUTAN mulai dari titik acak,
-- bukan 10 baris yang masing-masing acak. Untuk use case tertentu
-- ini sudah cukup, untuk yang lain perlu pendekatan berbeda.

Batasan pendekatan ini: jika banyak ID yang terhapus (ID tidak kontinu), distribusinya tidak merata — area dengan banyak ID dihapus akan jarang terpilih.

Kapan ID Sampling cocok:
  ✓ ID numerik auto-increment
  ✓ Penghapusan data jarang (ID relatif kontinu)
  ✓ Butuh 1 item acak, bukan banyak sekaligus
  ✓ Distribusi random tidak harus sempurna

Kapan tidak cocok:
  ✗ ID berupa UUID/string
  ✗ Banyak baris dihapus (ID bolong-bolong)
  ✗ Butuh distribusi yang benar-benar merata

Alternatif 2: Precomputed Random Value (Kolom rand_value) #

Ini adalah pendekatan yang paling scalable untuk tabel besar dengan kebutuhan random content yang konsisten. Idenya sederhana: simpan nilai random di kolom terindeks, lalu query menggunakan kolom itu.

-- Tambahkan kolom random ke tabel
ALTER TABLE articles
ADD COLUMN rand_value FLOAT NOT NULL DEFAULT 0,
ADD INDEX idx_articles_rand_value (rand_value);

-- Isi nilai random saat insert
INSERT INTO articles (title, content, rand_value)
VALUES ('Judul Artikel', 'Isi...', RAND());

-- Atau update massal untuk data yang sudah ada
UPDATE articles SET rand_value = RAND();

-- Query random yang sangat cepat
SELECT * FROM articles
ORDER BY rand_value
LIMIT 10;
-- → type: index, key: idx_articles_rand_value
-- → Scan index saja, tidak perlu baca semua baris
-- → Tidak ada sort runtime — sudah terurut di index

Untuk membuat tampilan random terasa “segar” setiap waktu, tambahkan mekanisme refresh berkala:

-- Refresh nilai random secara periodik (misal via cron job)
-- Jangan update semua sekaligus di production — bisa lock tabel
UPDATE articles
SET rand_value = RAND()
WHERE id BETWEEN ? AND ?;  -- batch per rentang ID

-- Atau trigger refresh saat konten baru ditambahkan
-- dengan mengacak ulang sebagian data lama
UPDATE articles
SET rand_value = RAND()
ORDER BY rand_value  -- update yang sudah lama "terpilih"
LIMIT 1000;
Kapan Precomputed Random Value cocok:
  ✓ Tabel besar (> 100 ribu baris)
  ✓ Random content yang diakses sering (homepage, widget)
  ✓ Boleh ada pengulangan antar sesi yang berbeda
  ✓ Ada mekanisme untuk refresh nilai secara berkala

Kapan tidak cocok:
  ✗ Butuh random yang benar-benar baru setiap request
  ✗ Tabel yang sangat jarang diakses (overhead kolom tidak worth it)

Alternatif 3: Shuffle di Application Layer #

Pendekatan ini memindahkan logika pengacakan dari database ke aplikasi. Database hanya bertugas mengembalikan daftar ID — pekerjaan ringan yang memanfaatkan index — lalu aplikasi yang mengacak dan memilih.

// Contoh implementasi di Go

// Langkah 1: ambil semua ID dari database (pakai index → cepat)
rows, err := db.Query("SELECT id FROM articles WHERE status = 'published'")
// → Hanya mengambil kolom ID, bukan semua kolom
// → Memanfaatkan index di kolom status + id

var ids []int64
for rows.Next() {
    var id int64
    rows.Scan(&id)
    ids = append(ids, id)
}

// Langkah 2: shuffle di application layer
rand.Shuffle(len(ids), func(i, j int) {
    ids[i], ids[j] = ids[j], ids[i]
})

// Langkah 3: ambil 10 ID pertama setelah shuffle
selectedIDs := ids[:10]

// Langkah 4: query data lengkap hanya untuk ID yang dipilih
// IN dengan 10 ID menggunakan index PRIMARY → sangat cepat
query := "SELECT * FROM articles WHERE id IN (?,?,?,?,?,?,?,?,?,?)"
result, err := db.Query(query, selectedIDs...)
Kapan Application Layer Shuffle cocok:
  ✓ Total jumlah ID tidak terlalu besar (< 100 ribu)
  ✓ Random harus berbeda setiap request
  ✓ Ada caching untuk daftar ID (tidak perlu query ulang setiap request)
  ✓ Kontrol penuh atas logika randomisasi di aplikasi

Kapan tidak cocok:
  ✗ Jutaan ID — transfer semua ID ke aplikasi terlalu berat
  ✗ Memory aplikasi terbatas
  ✗ Tidak ada caching untuk daftar ID
Kombinasi yang efektif: cache daftar ID di Redis dengan TTL beberapa menit. Setiap request mengambil daftar dari cache, shuffle di memory, ambil 10 pertama. Database hanya dipanggil saat cache expire — bukan setiap request.

Alternatif 4: Bucket Sampling dengan Modulo #

Pendekatan ini membagi tabel ke dalam “bucket” berdasarkan modulo primary key, lalu memilih bucket secara acak. Efeknya adalah database hanya perlu membaca sebagian kecil tabel.

-- Bagi tabel ke 100 bucket, pilih satu bucket secara acak
SET @bucket = FLOOR(RAND() * 100);

SELECT *
FROM articles
WHERE id % 100 = @bucket
  AND status = 'published'
LIMIT 10;

-- Catatan: jika bucket yang dipilih punya < 10 baris,
-- query menghasilkan kurang dari LIMIT. Tangani di aplikasi.

Untuk tabel dengan distribusi ID yang tidak merata, bisa dikombinasikan dengan range:

-- Pilih rentang acak dari tabel
SELECT *
FROM articles
WHERE id BETWEEN
    FLOOR(RAND() * (SELECT MAX(id) FROM articles) * 0.9)
    AND
    FLOOR(RAND() * (SELECT MAX(id) FROM articles))
  AND status = 'published'
LIMIT 10;
Kapan Bucket Sampling cocok:
  ✓ Tabel sangat besar dan ID numerik kontinu
  ✓ Boleh ada sedikit bias dalam distribusi
  ✓ Butuh solusi cepat tanpa perubahan schema
  ✓ Tidak butuh jumlah baris yang persis (bisa < LIMIT)

Kapan tidak cocok:
  ✗ Butuh tepat N baris setiap kali
  ✗ ID tidak numerik atau tidak kontinu
  ✗ Distribusi harus sangat merata

Perbandingan Performa Keempat Alternatif #

Untuk membantu memilih pendekatan yang tepat, berikut perbandingan langsung keempat alternatif berdasarkan karakteristik yang paling penting:

Perbandingan Alternatif ORDER BY RAND():
──────────────────────────────────────────────────────────────────────
  Pendekatan              │ Skalabilitas │ Randomness │ Kompleksitas
──────────────────────────────────────────────────────────────────────
  ORDER BY RAND()         │ ✗ Sangat buruk│ ✓ Sempurna │ ✓ Sangat mudah
  ID Sampling (MAX id)    │ ✓ Baik        │ ~ Cukup    │ ✓ Mudah
  Precomputed rand_value  │ ✓ Sangat baik │ ~ Cukup    │ ~ Sedang
  App Layer Shuffle       │ ~ Sedang      │ ✓ Baik     │ ~ Sedang
  Bucket Modulo           │ ✓ Baik        │ ~ Cukup    │ ✓ Mudah
──────────────────────────────────────────────────────────────────────

Decision tree:
  Tabel > 500 ribu baris?
    │
    ├─ Ya → Apakah schema bisa diubah?
    │         ├─ Ya  → Precomputed rand_value (terbaik untuk skala besar)
    │         └─ Tidak → ID Sampling atau Bucket Modulo
    │
    └─ Tidak → Apakah random harus sempurna setiap request?
                  ├─ Ya  → App Layer Shuffle + Redis cache ID list
                  └─ Tidak → ID Sampling sudah cukup

ORDER BY COUNT: Masalah yang Sama, Konteks Berbeda #

ORDER BY COUNT adalah pola berbeda tapi punya akar masalah yang serupa: database dipaksa melakukan komputasi berat setiap query dijalankan, tanpa bisa memanfaatkan index secara optimal.

Mengapa ORDER BY COUNT Mahal #

-- Query yang terlihat wajar untuk "artikel terpopuler"
SELECT a.id, a.title, COUNT(v.id) AS view_count
FROM articles a
LEFT JOIN views v ON a.id = v.article_id
GROUP BY a.id, a.title
ORDER BY view_count DESC
LIMIT 10;

Apa yang terjadi di balik layar:

Proses eksekusi ORDER BY COUNT:
──────────────────────────────────────────────────────────────
  Langkah 1: JOIN antara articles dan views
    → Baca seluruh tabel views (misalnya 50 juta baris)
    → Cocokkan setiap baris views ke article-nya

  Langkah 2: GROUP BY article_id
    → Kelompokkan 50 juta baris berdasarkan article_id
    → Buat tabel temporary untuk menyimpan hasil grouping
    → Using temporary di EXPLAIN

  Langkah 3: COUNT(*) per group
    → Hitung jumlah baris di setiap group
    → Operasi aggregate yang tidak bisa di-index

  Langkah 4: ORDER BY view_count
    → Sort hasil aggregate
    → Using filesort di EXPLAIN

  Total: query ini dieksekusi ulang dari awal setiap request.
  Jika endpoint ini dipanggil 1000x/menit → database kelelahan.
──────────────────────────────────────────────────────────────

Solusi 1: Counter Table (Pre-Aggregation) #

Ini adalah pendekatan terbaik untuk counter yang sering diquery. Alih-alih menghitung ulang setiap request, simpan hasilnya di tabel terpisah dan update secara incremental.

-- Buat tabel counter khusus
CREATE TABLE article_stats (
    article_id  BIGINT UNSIGNED NOT NULL,
    view_count  BIGINT UNSIGNED NOT NULL DEFAULT 0,
    like_count  BIGINT UNSIGNED NOT NULL DEFAULT 0,
    share_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
    updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (article_id),
    INDEX idx_article_stats_view_count (view_count DESC)
) ENGINE=InnoDB;

-- Update counter setiap ada view (di application layer)
INSERT INTO article_stats (article_id, view_count)
VALUES (?, 1)
ON DUPLICATE KEY UPDATE
    view_count = view_count + 1;

-- Query artikel terpopuler: sangat cepat
SELECT a.id, a.title, s.view_count
FROM articles a
JOIN article_stats s ON a.id = s.article_id
ORDER BY s.view_count DESC
LIMIT 10;
-- → type: index di article_stats (pakai idx_article_stats_view_count)
-- → Tidak ada GROUP BY, tidak ada aggregate, tidak ada filesort
-- ANTI-PATTERN: hitung COUNT setiap request
SELECT a.id, a.title, COUNT(v.id) AS view_count
FROM articles a
LEFT JOIN views v ON a.id = v.article_id
GROUP BY a.id
ORDER BY view_count DESC
LIMIT 10;
-- → Full join, GROUP BY, filesort — dieksekusi ulang setiap request

-- BENAR: baca dari counter table yang sudah ter-aggregasi
SELECT a.id, a.title, s.view_count
FROM articles a
JOIN article_stats s ON a.id = s.article_id
ORDER BY s.view_count DESC
LIMIT 10;
-- → Index scan di article_stats, JOIN ke articles via PK
-- → Tidak ada komputasi — hanya membaca data yang sudah dihitung

Solusi 2: Batch Aggregation untuk Eventual Consistency #

Untuk ranking yang tidak perlu real-time, jalankan agregasi secara periodik dan simpan hasilnya:

-- Jalankan via cron job setiap 5 menit atau setiap jam
-- Hitung ulang stats dari tabel views
INSERT INTO article_stats (article_id, view_count)
SELECT
    article_id,
    COUNT(*) AS view_count
FROM views
WHERE created_at >= NOW() - INTERVAL 24 HOUR
GROUP BY article_id
ON DUPLICATE KEY UPDATE
    view_count = VALUES(view_count);

-- Bersihkan data views lama jika tidak dibutuhkan lagi
DELETE FROM views WHERE created_at < NOW() - INTERVAL 30 DAY;

Pendekatan ini cocok untuk fitur seperti “Trending Hari Ini” atau “Artikel Terpopuler Minggu Ini” — user tidak perlu tahu bahwa data diperbarui setiap 5 menit, bukan real-time.

Solusi 3: Redis untuk Counter Real-Time #

Untuk counter yang harus real-time dengan traffic tinggi, gunakan Redis sebagai layer counter dan flush ke database secara berkala:

// Di application layer (Go)

// Setiap ada view, increment di Redis (sangat cepat, non-blocking)
func recordView(articleID int64) {
    key := fmt.Sprintf("views:article:%d", articleID)
    rdb.Incr(ctx, key)
    rdb.Expire(ctx, key, 24*time.Hour)
}

// Worker yang berjalan setiap 1 menit: flush Redis ke database
func flushViewsToDB() {
    pattern := "views:article:*"
    keys, _ := rdb.Keys(ctx, pattern).Result()

    for _, key := range keys {
        count, _ := rdb.GetDel(ctx, key).Int64()
        articleID := extractIDFromKey(key)

        db.Exec(`
            INSERT INTO article_stats (article_id, view_count)
            VALUES (?, ?)
            ON DUPLICATE KEY UPDATE view_count = view_count + ?
        `, articleID, count, count)
    }
}

// Query ranking: baca dari database yang sudah di-flush
// Real-time counter ada di Redis, persistensi ada di DB
Perbandingan Solusi ORDER BY COUNT:
──────────────────────────────────────────────────────────────────
  Pendekatan           │ Real-time │ DB Load  │ Kompleksitas
──────────────────────────────────────────────────────────────────
  COUNT setiap query   │ ✓ Ya      │ ✗ Sangat tinggi│ ✓ Mudah
  Counter Table        │ ✓ Ya      │ ✓ Rendah  │ ~ Sedang
  Batch Aggregation    │ ~ 5 menit │ ✓ Rendah  │ ~ Sedang
  Redis + Flush        │ ✓ Ya      │ ✓ Sangat rendah│ ✗ Tinggi
──────────────────────────────────────────────────────────────────
  Rekomendasi default: Counter Table
  Jika traffic sangat tinggi: Redis + Flush
  Jika tidak perlu real-time: Batch Aggregation

Anti-Pattern yang Harus Dihindari #

-- ✗ Anti-pattern 1: ORDER BY RAND() di tabel besar
SELECT * FROM products WHERE category_id = 5 ORDER BY RAND() LIMIT 6;
-- ✓ Solusi: precomputed rand_value dengan index

-- ✗ Anti-pattern 2: ORDER BY RAND() di dalam subquery
SELECT * FROM articles
WHERE id IN (
    SELECT id FROM articles ORDER BY RAND() LIMIT 100
);
-- ✓ Solusi: ambil ID di aplikasi, shuffle, query dengan IN

-- ✗ Anti-pattern 3: COUNT(*) untuk ranking di query user-facing
SELECT category_id, COUNT(*) AS total
FROM products
GROUP BY category_id
ORDER BY total DESC;
-- ✓ Solusi: counter table yang diupdate saat insert/delete produk

-- ✗ Anti-pattern 4: RAND() di WHERE sebagai filter sampling
SELECT * FROM logs WHERE RAND() < 0.01;  -- ambil 1% data secara acak
-- Database tetap scan semua baris, evaluasi RAND() per baris
-- ✓ Solusi untuk sampling: gunakan modulo ID atau reservoir sampling di aplikasi

-- ✗ Anti-pattern 5: refresh rand_value dengan UPDATE semua baris sekaligus
UPDATE articles SET rand_value = RAND();
-- Lock tabel, bisa menyebabkan downtime
-- ✓ Solusi: update dalam batch kecil dengan LIMIT dan loop di aplikasi

Ringkasan #

  • ORDER BY RAND() selalu full table scan — database harus membaca, mengacak, dan mengurutkan seluruh tabel sebelum bisa mengambil LIMIT baris. Di tabel besar ini adalah bencana performa.
  • RAND() tidak bisa di-index karena nilainya tidak deterministik — setiap pemanggilan menghasilkan nilai berbeda, sehingga tidak ada yang bisa disimpan di index.
  • Precomputed rand_value adalah solusi terbaik untuk tabel besar — tambahkan kolom float ter-index yang diisi saat insert, refresh berkala via batch, dan query menggunakan ORDER BY rand_value.
  • ID sampling cocok untuk solusi cepat tanpa ubah schema — tapi distribusinya tidak merata jika banyak ID yang dihapus.
  • Application layer shuffle efektif untuk dataset sedang — cache daftar ID di Redis, shuffle di memory, query dengan WHERE id IN (...) yang memanfaatkan index primary key.
  • ORDER BY COUNT punya masalah yang sama — komputasi aggregate yang diulangi setiap request tidak scalable. Solusinya adalah memisahkan penulisan dan pembacaan counter.
  • Counter table adalah gold standard untuk ranking — update incremental saat data berubah, query hanya membaca kolom yang sudah ter-agregasi tanpa GROUP BY runtime.
  • Redis + flush cocok untuk counter real-time dengan traffic tinggi — increment di Redis (microsecond), flush ke database secara berkala, database tetap ringan.

← Sebelumnya: Type on Join   Berikutnya: Image on Table →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact