Dalam pemrograman, kita belajar berhati-hati terhadap nilai yang tidak ada karena sering memicu perilaku tak terduga. Di SQL, bentuk nyatanya adalah NULL. Masalahnya bukan hanya hasil query yang berbeda dari dugaan, tetapi juga performa: query bisa lambat, planner salah memilih rencana eksekusi, dan index yang seharusnya membantu malah tidak dipakai.
Jawaban singkat untuk pertanyaan pada judul adalah ini: NULL mengubah cara database mengevaluasi predicate, menghitung selectivity, dan menavigasi index. Jika query ditulis dengan pola yang kurang tepat—misalnya membungkus kolom dengan COALESCE() di WHERE, memakai kondisi OR yang terlalu umum, atau mengandalkan ORDER BY pada kolom yang banyak NULL—optimizer sering kesulitan memakai index secara efektif.
Artikel ini fokus pada MySQL/PostgreSQL secara umum, tanpa klaim yang terlalu spesifik per versi. Tujuannya praktis: memahami mengapa NULL memengaruhi performa, cara memeriksanya dengan EXPLAIN atau EXPLAIN ANALYZE, serta opsi perbaikan pada query, index, dan skema.
Memahami NULL: bukan kosong, bukan nol, dan bukan string kosong
NULL berarti nilai tidak diketahui atau tidak ada. Ini berbeda dari:
0untuk angka,''untuk string kosong,falseuntuk boolean.
Karena NULL bukan nilai biasa, perbandingan standar tidak berlaku seperti yang sering diasumsikan:
SELECT 1 WHERE NULL = NULL; -- tidak true
SELECT 1 WHERE NULL <> 5; -- tidak true
SELECT 1 WHERE NULL IS NULL; -- trueInilah alasan predicate dengan NULL harus ditulis memakai IS NULL atau IS NOT NULL, bukan = NULL atau <> NULL.
Three-valued logic: TRUE, FALSE, UNKNOWN
SQL memakai three-valued logic: hasil ekspresi boolean bisa TRUE, FALSE, atau UNKNOWN. Nilai UNKNOWN biasanya muncul ketika NULL terlibat dalam perbandingan.
Contoh:
WHERE deleted_at = NULL -- salah, hasilnya UNKNOWN
WHERE deleted_at IS NULL -- benar
WHERE status <> 'done' -- baris dengan status NULL tidak ikut
WHERE status IS DISTINCT FROM 'done' -- PostgreSQL, jika butuh semantik berbedaDampak praktisnya besar. Kadang pengembang merasa query “benar”, tetapi hasilnya tidak lengkap. Di sisi performa, predicate yang rumit karena harus menangani NULL sering membuat planner lebih hati-hati, atau bahkan gagal memakai index secara optimal.
Mengapa NULL di SQL bisa membuat query lambat
1. Selectivity dan cardinality jadi sulit diperkirakan
Optimizer memilih index berdasarkan estimasi jumlah baris yang cocok dengan kondisi query. Estimasi ini bergantung pada statistik data: sebaran nilai, jumlah nilai unik, proporsi NULL, dan korelasi antarkolom.
Jika sebuah kolom berisi sangat banyak NULL, maka predicate seperti:
WHERE archived_at IS NULLbisa berarti dua hal yang sangat berbeda:
- hanya sedikit baris yang aktif, sehingga index sangat berguna, atau
- hampir semua baris aktif, sehingga sequential scan / full scan lebih murah.
Tanpa statistik yang akurat, planner bisa memilih rencana yang keliru. Itulah mengapa dua query yang sama pada tabel berbeda bisa memiliki performa sangat berbeda, padahal sama-sama memakai IS NULL.
2. Predicate dengan fungsi sering mematikan index
Masalah yang sangat umum adalah membungkus kolom nullable dengan fungsi di bagian WHERE. Contoh anti-pattern:
SELECT id, email
FROM users
WHERE COALESCE(deleted_at, created_at) > NOW() - INTERVAL '30 days';atau:
SELECT *
FROM orders
WHERE COALESCE(shipped_at, '9999-12-31') > CURRENT_DATE;Secara logika mungkin benar, tetapi secara performa sering buruk. Ketika kolom dibungkus fungsi, optimizer tidak selalu bisa memakai index biasa pada deleted_at atau shipped_at, karena yang dibandingkan bukan lagi nilai kolom mentah melainkan hasil ekspresi.
Prinsip umumnya: usahakan kolom di sisi kiri predicate tetap “telanjang”, lalu pindahkan logika ke bentuk yang lebih ramah index.
Contoh perbaikan:
-- anti-pattern
WHERE COALESCE(deleted_at, created_at) > :cutoff
-- lebih mudah dioptimalkan, tergantung kebutuhan bisnis
WHERE deleted_at > :cutoff
OR (deleted_at IS NULL AND created_at > :cutoff)Trade-off-nya: bentuk kedua lebih panjang dan kadang memerlukan index tambahan agar benar-benar efisien.
3. OR yang melibatkan NULL sering memperluas hasil terlalu banyak
Predicate seperti ini juga sering bermasalah:
WHERE processed_at IS NULL OR processed_at > NOW()Jika banyak baris bernilai NULL, kondisi menjadi sangat tidak selektif. Akibatnya, index scan bisa lebih mahal daripada membaca tabel lebih langsung. Di beberapa kasus, memecah query menjadi dua bagian lalu menggabungkannya dapat lebih mudah dioptimalkan, meskipun tidak selalu perlu.
IS NULL, IS NOT NULL, dan perilaku index
Secara umum, baik MySQL maupun PostgreSQL dapat memakai index untuk predicate IS NULL atau IS NOT NULL. Namun, bisa memakai tidak sama dengan pasti akan memakai. Optimizer tetap mempertimbangkan biaya.
Kapan IS NULL cenderung cepat
- Jumlah baris NULL relatif sedikit.
- Kolom memiliki index yang relevan.
- Query mengambil sedikit kolom sehingga bisa memanfaatkan index-only access dalam kondisi tertentu.
- Predicate NULL dikombinasikan dengan kondisi lain yang makin selektif.
Kapan IS NULL tetap lambat
- Sebagian besar baris bernilai NULL.
- Statistik tabel basi atau tidak representatif.
- Query tetap harus mengambil banyak kolom dari heap/table.
- Predicate dibungkus fungsi atau ekspresi non-sargable.
Contoh query lambat
Misalkan ada tabel pekerjaan background:
CREATE TABLE jobs (
id BIGINT PRIMARY KEY,
queue_name VARCHAR(50) NOT NULL,
available_at TIMESTAMP NOT NULL,
reserved_at TIMESTAMP NULL,
payload TEXT NOT NULL
);Query yang umum:
SELECT id, queue_name, available_at
FROM jobs
WHERE queue_name = 'email'
AND reserved_at IS NULL
AND available_at <= NOW()
ORDER BY available_at, id
LIMIT 50;Jika index hanya ada pada queue_name, database mungkin masih harus memeriksa sangat banyak baris untuk menemukan mana yang reserved_at IS NULL dan available_at <= NOW(). Ini sering terasa lambat ketika antrean membesar.
Index yang lebih cocok biasanya mengikuti pola filter dan urutan hasil:
-- contoh konsep, bukan resep mutlak
CREATE INDEX idx_jobs_fetch
ON jobs (queue_name, reserved_at, available_at, id);Apakah urutannya ideal bergantung pada distribusi data dan pola query. Namun intinya, NULL bukan satu-satunya faktor; ia harus dilihat bersama kolom filter lain dan ORDER BY.
Cardinality, selectivity, dan kenapa planner kadang salah pilih
Dua istilah penting:
- Cardinality: perkiraan jumlah baris hasil suatu langkah query.
- Selectivity: seberapa sempit sebuah predicate menyaring data.
Predicate yang sangat selektif cocok untuk index. Predicate yang tidak selektif sering membuat scan tabel lebih murah.
NULL berpengaruh langsung ke selectivity. Misalnya:
deleted_at IS NULLpada tabel soft delete bisa sangat tidak selektif jika hampir semua data belum dihapus.processed_at IS NOT NULLbisa sangat selektif jika hanya sedikit data yang sudah diproses.
Kesalahan umum adalah menganggap “ada index di kolom itu” berarti query pasti cepat. Kenyataannya, jika kondisi cocok dengan 80–95% tabel, optimizer bisa sengaja mengabaikan index karena biaya random access lebih tinggi daripada scan berurutan.
Catatan praktis: jika performa tiba-tiba berubah setelah pertumbuhan data, periksa statistik tabel dan rencana eksekusi. Bukan tidak mungkin query yang dulu cepat sekarang lambat karena distribusi NULL berubah.
Composite index: urutan kolom menentukan hasil
Untuk query nyata, NULL jarang berdiri sendiri. Biasanya ada bersama filter lain, sorting, dan pagination. Di sinilah composite index lebih penting daripada index tunggal.
Contoh pola umum
SELECT id, customer_id, created_at
FROM invoices
WHERE paid_at IS NULL
AND customer_id = 123
ORDER BY created_at DESC
LIMIT 20;Index berikut mungkin lebih berguna daripada index terpisah pada masing-masing kolom:
CREATE INDEX idx_invoices_customer_paid_created
ON invoices (customer_id, paid_at, created_at DESC);Alasannya:
customer_id = 123menyaring domain lebih dulu.paid_at IS NULLmempersempit hasil dalam kelompok customer itu.created_at DESCmembantu menghindari sort tambahan.
Namun ada trade-off:
- Index lebih besar.
- Biaya insert/update naik.
- Tidak semua query lain akan cocok dengan urutan kolom yang sama.
Kesalahan umum pada composite index
- Membuat banyak index tunggal, berharap optimizer selalu bisa menggabungkannya secara efisien.
- Menaruh kolom nullable di urutan yang tidak sesuai pola query dominan.
- Mengabaikan
ORDER BYsehingga query tetap harus melakukan sort mahal.
Partial/filtered index: sangat efektif untuk data dengan banyak NULL
Jika hanya sebagian data yang sering diakses, partial index (PostgreSQL) atau pendekatan setara sesuai fitur database dapat sangat membantu. Ini sangat relevan untuk pola seperti soft delete, job queue, atau status yang jarang.
Contoh PostgreSQL
CREATE INDEX idx_jobs_ready
ON jobs (queue_name, available_at, id)
WHERE reserved_at IS NULL;Keuntungannya:
- Index lebih kecil karena hanya berisi baris yang memenuhi predicate.
- Scan index lebih cepat untuk query yang memang mencari
reserved_at IS NULL. - Biaya pemeliharaan index bisa lebih rendah dibanding index penuh pada semua baris.
Ini sangat cocok jika query dominan memang menargetkan subset yang sama. Kekurangannya: index tersebut tidak membantu query yang mencari reserved_at IS NOT NULL.
Pada MySQL, tidak ada partial index dengan semantik yang sama seperti PostgreSQL. Karena itu, optimasi biasanya mengandalkan:
- composite index yang tepat,
- kolom turunan/generator yang stabil bila memang perlu,
- atau perubahan skema agar kondisi lebih mudah diindeks.
Gunakan pendekatan ini hati-hati agar tidak menambah kompleksitas tanpa manfaat nyata.
ORDER BY, NULL, dan pagination
NULL memengaruhi urutan hasil
Saat melakukan ORDER BY, NULL punya posisi tersendiri dan perilakunya bisa berbeda antar database atau bergantung arah sorting. Karena itu, query yang tampak sepele dapat menghasilkan urutan yang tidak stabil, terutama ketika banyak baris memiliki nilai NULL yang sama-sama “tidak diketahui”.
Contoh:
SELECT id, published_at
FROM articles
ORDER BY published_at DESC
LIMIT 20;Jika banyak baris punya published_at NULL, urutan di antara mereka belum tentu stabil. Untuk pagination, ini berbahaya karena halaman bisa loncat, duplikat, atau melewatkan baris ketika data berubah.
Tambahkan tie-breaker
Gunakan kolom unik tambahan di ORDER BY:
SELECT id, published_at
FROM articles
ORDER BY published_at DESC, id DESC
LIMIT 20;Ini penting bukan hanya untuk kebenaran hasil, tetapi juga agar index bisa lebih sesuai dengan urutan yang diminta.
Hindari OFFSET besar pada data nullable
Pagination berbasis LIMIT ... OFFSET ... pada kolom yang banyak NULL sering mahal karena database tetap harus menghitung dan melewati banyak baris.
Lebih baik gunakan keyset pagination bila memungkinkan:
-- halaman pertama
SELECT id, published_at
FROM articles
WHERE published_at IS NOT NULL
ORDER BY published_at DESC, id DESC
LIMIT 20;
-- halaman berikutnya
SELECT id, published_at
FROM articles
WHERE published_at IS NOT NULL
AND (published_at, id) < (:last_published_at, :last_id)
ORDER BY published_at DESC, id DESC
LIMIT 20;Jika Anda memang harus mencampur baris NULL dan non-NULL dalam urutan yang sama, desain pagination perlu lebih hati-hati karena batas antar kelompok menjadi lebih rumit.
Anti-pattern yang sering membuat index tidak terpakai
1. COALESCE di WHERE
-- anti-pattern
WHERE COALESCE(last_login_at, created_at) > :cutoffMasalah: fungsi pada kolom membuat predicate sulit dioptimalkan dengan index biasa.
Pertimbangkan pecah logika:
WHERE last_login_at > :cutoff
OR (last_login_at IS NULL AND created_at > :cutoff)2. Membandingkan NULL dengan operator biasa
-- salah
WHERE deleted_at = NULL
WHERE deleted_at <> NULLGunakan:
WHERE deleted_at IS NULL
WHERE deleted_at IS NOT NULL3. Menyimpan makna bisnis ganda dalam NULL
Contoh: kolom shipped_at bernilai NULL bisa berarti “belum diproses”, “gagal sinkron”, atau “data lama belum dimigrasi”. Dari sudut performa dan desain, ini buruk. Query jadi penuh cabang logika dan sulit diindeks dengan baik.
Jika maknanya memang berbeda, pisahkan dengan kolom status yang eksplisit.
4. Soft delete tanpa mempertimbangkan pola akses
Pola:
WHERE deleted_at IS NULLsangat umum, tetapi jika hampir semua query memakainya, pertimbangkan strategi index khusus atau bahkan pemisahan data aktif dan arsip bila skala dan pola akses membenarkannya.
Membaca EXPLAIN dan EXPLAIN ANALYZE untuk kasus NULL
Jangan menebak. Periksa rencana eksekusi.
Contoh query
EXPLAIN ANALYZE
SELECT id, queue_name, available_at
FROM jobs
WHERE queue_name = 'email'
AND reserved_at IS NULL
AND available_at <= NOW()
ORDER BY available_at, id
LIMIT 50;Hal yang perlu dicari
- Jenis scan: sequential scan, index scan, bitmap scan, range scan, dan sejenisnya.
- Estimated rows vs actual rows: jika selisihnya besar, statistik atau model selectivity bisa bermasalah.
- Filter vs index condition: apakah kondisi
IS NULLditerapkan saat menavigasi index atau baru difilter belakangan. - Sort: apakah database harus melakukan sort tambahan karena urutan index tidak cocok dengan
ORDER BY. - Loops / repeated scans: penting jika query berada di dalam nested loop.
Interpretasi praktis
Jika Anda melihat scan index tetapi jumlah baris yang dibaca tetap sangat banyak, itu belum tentu bagus. Bisa jadi index dipakai, tetapi tidak selektif. Sebaliknya, jika planner memilih full scan, jangan langsung menganggap database “bodoh”; mungkin memang lebih murah untuk distribusi data saat itu.
Tips debugging: setelah membuat index baru, uji lagi dengan
EXPLAIN. Jika hasil tidak berubah, periksa apakah query benar-benar cocok dengan urutan kolom index, apakah statistik sudah diperbarui, dan apakah predicate Anda tetap non-sargable karena fungsi atau ekspresi.
Strategi perbaikan: query, index, atau skema?
1. Perbaiki query lebih dulu
- Gunakan
IS NULL/IS NOT NULLdengan benar. - Hindari fungsi pada kolom di
WHEREjika memungkinkan. - Kurangi
ORyang membuat hasil terlalu lebar. - Tambahkan tie-breaker pada
ORDER BY.
2. Sesuaikan index dengan pola akses nyata
- Buat composite index berdasarkan kombinasi filter, sorting, dan limit yang paling sering dipakai.
- Pertimbangkan partial/filtered index jika database mendukung dan subset NULL/non-NULL sangat dominan.
- Hindari membuat terlalu banyak index hanya karena satu query lambat; ukur dampaknya pada write.
3. Evaluasi desain skema
Jika NULL membawa terlalu banyak arti bisnis, pertimbangkan perubahan skema:
- pisahkan kolom status dari timestamp,
- gunakan tabel arsip untuk data yang jarang diakses,
- buat kolom turunan yang eksplisit jika benar-benar membantu query dominan.
Contoh:
-- Daripada hanya mengandalkan processed_at NULL/non-NULL
status ENUM('pending', 'processing', 'done', 'failed')
processed_at TIMESTAMP NULLDengan ini, query bisa lebih jelas secara bisnis dan kadang lebih mudah dioptimalkan.
Kapan NULL sebaiknya dipertahankan, kapan sebaiknya dihindari
Pertahankan NULL jika
- nilai memang opsional secara domain,
- arti “belum diketahui” valid dan berbeda dari nilai default,
- query atas kolom itu tidak kritis atau bisa dioptimalkan dengan baik.
Hindari atau batasi NULL jika
- kolom dipakai terus-menerus dalam filter performa-kritis,
- NULL dipakai untuk mewakili banyak status berbeda,
- query menjadi penuh workaround seperti
COALESCE,CASE, danORkompleks.
Checklist singkat untuk investigasi query lambat terkait NULL
- Apakah predicate memakai
IS NULL/IS NOT NULLdengan benar? - Apakah ada fungsi seperti
COALESCE,LOWER, atauDATE()pada kolom diWHERE? - Berapa proporsi NULL di kolom tersebut?
- Apakah index tunggal cukup, atau perlu composite index?
- Apakah
ORDER BYcocok dengan urutan index? - Apakah pagination memakai OFFSET besar?
- Apa kata
EXPLAINatauEXPLAIN ANALYZEtentang estimasi vs realita? - Apakah statistik tabel masih relevan setelah distribusi data berubah?
Penutup
NULL di SQL sering membuat query lambat bukan karena NULL itu “jahat”, melainkan karena ia mengubah logika evaluasi, selectivity, dan strategi akses data. Efeknya paling terasa saat query melibatkan IS NULL/IS NOT NULL, sorting, pagination, dan index gabungan.
Prinsip praktisnya sederhana: tulis predicate yang ramah index, ukur dengan EXPLAIN, pahami distribusi data, lalu pilih perbaikan yang tepat—apakah cukup mengubah query, membuat composite/partial index, atau merapikan skema agar NULL tidak memikul terlalu banyak makna bisnis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!