Database Saya "Benar." Juga 296x Terlalu Lambat.
Saya menemukan database Postgres saya punya 89 foreign key tapi nol index — mengubah query milidetik menjadi mimpi buruk 843ms dan hampir membunuh peluncuran alpha saya.
25 Oktober 2025. Aplikasi SaaS saya akhirnya feature-complete. Sembilan fitur utama, arsitektur multi-tenant, progressive learning — semuanya berfungsi. Tapi lambat. Sangat lambat.
Query dashboard merangkak. List yang seharusnya dimuat seketika butuh beberapa detik. Alpha tester mulai bertanya "Apakah situsnya rusak?"
Saya bukan hanya khawatir soal performa. Saya khawatir soal kelangsungan hidup.
Saya menghabiskan dua hari mengoptimasi query, menulis ulang RLS policies, menambah caching. Tidak ada yang berpengaruh. Frustrasi meningkat — 5 hari dari peluncuran alpha yang direncanakan dan saya tidak bisa menemukan kenapa database yang secara teknis benar berkinerja seburuk ini.
Lalu saya menjalankan satu query diagnostik yang membuat perut saya jatuh.
89 foreign key. Nol index.
Ini cerita tentang "fitur" Postgres yang tidak diceritakan siapa pun — 2 minggu yang hampir membunuh peluncuran alpha saya — dan perbaikan 4 menit yang menyelamatkan segalanya.
Taruhannya: Ketika Lambat Berarti Mati
Riset menunjukkan setiap 100ms latensi mengorbankan 1% konversi. Pada waktu loading 2-3 detik, saya bukan hanya memberikan pengalaman buruk — saya menghancurkan kesan pertama yang akan menentukan berhasil tidaknya alpha saya.
Taruhan langsung:
- Alpha tester mempertanyakan apakah platform cukup stabil untuk direkomendasikan
- Kehilangan kredibilitas dengan pengguna awal yang sudah memberikan kepercayaan
- Database CPU di 80-90% (mahal dan semakin buruk)
- 5 hari dari peluncuran yang direncanakan, sepenuhnya terblokir
Yang sebenarnya dikorbankan 2 minggu itu:
- 40+ jam debugging query lambat, profiling kode, menyalahkan RLS policies
- 1 minggu peluncuran tertunda (melewatkan deadline internal)
- Kehilangan kesempatan membangun fitur
- Kerusakan kredibilitas dengan early tester
Hutang teknis menjadi hutang bisnis. Dan saya tidak tahu kenapa.
---
Investigasi: Mengikuti Query yang Lambat
Pada akhir Oktober, platform saya punya kompleksitas nyata: 30+ tabel, 80+ RLS policies, ratusan relasi foreign key. Operasi sederhana yang seharusnya instan terasa lambat — memuat list, mengambil data dashboard, memfilter berdasarkan `org_id`.
Saya mulai dengan `pg_stat_statements` untuk menemukan pelakunya:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
```
Setiap query lambat punya pola yang sama — memfilter berdasarkan foreign key:
```sql
SELECT * FROM user_data
WHERE org_id = '...' AND resource_id = '...';
```
Seharusnya instan. Saya punya kolom-kolom itu. Saya punya RLS policies. Kenapa lambat?
Saya cek execution plan:
```sql
EXPLAIN ANALYZE
SELECT * FROM user_data
WHERE org_id = 'abc-123' AND resource_id = 'xyz-789';
```
Hasilnya membuat perut saya tenggelam:
```
Seq Scan on user_data (cost=0.00..2847.23 rows=15 width=1024)
Filter: ((org_id = 'abc-123') AND (resource_id = 'xyz-789'))
Rows Removed by Filter: 12,834
Execution Time: 843.271 ms
```
Sequential scan. Postgres membaca setiap baris di tabel, lalu memfilter. Pada query yang seharusnya butuh milidetik.
Tapi saya punya foreign key di `org_id` dan `resource_id`. Bukannya itu harusnya ter-index?
---
Pengungkapan: Foreign Key Tidak Auto-Index
Ini yang saya pelajari hari itu, dengan cara yang sulit:
Postgres secara otomatis membuat index untuk PRIMARY KEY dan UNIQUE constraint.
Postgres TIDAK secara otomatis membuat index untuk foreign key.
Biar saya katakan lagi, karena satu kesalahpahaman ini menghabiskan 2 minggu hidup saya:
FOREIGN KEY ≠ INDEX
Ketika kamu menulis ini:
```sql
CREATE TABLE user_data (
id UUID PRIMARY KEY, -- ✅ Otomatis ter-index
org_id UUID REFERENCES organizations(id), -- ❌ TIDAK ter-index!
resource_id UUID REFERENCES resources(id), -- ❌ TIDAK ter-index!
created_at TIMESTAMPTZ
);
```
Postgres membuat constraint foreign key (referential integrity), tapi tidak membuat index di `org_id` atau `resource_id`.
Kenapa? Karena Postgres tidak bisa mengasumsikan bagaimana kamu akan query datanya. Mungkin kamu tidak pernah memfilter berdasarkan foreign key. Mungkin kamu selalu join ke arah tertentu. Jadi keputusan diserahkan padamu.
Masalahnya? Saya tidak tahu saya perlu membuat keputusan itu. Saya mengasumsikan "foreign key" berarti "ter-index untuk query." Ternyata tidak.
---
Diagnostik: Seberapa Parah?
Saya menulis query untuk menemukan setiap foreign key tanpa index:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
Hasilnya menghantam saya seperti kereta barang:
89 baris.
89 foreign key di 32 tabel. Nol index.
Setiap join, setiap filter, setiap pengecekan RLS policy melakukan full table scan. Pantas semuanya lambat.
---
Perbaikannya: Index Apocalypse
25 Oktober 2025. 7:51 AM. Saya tahu apa yang harus dilakukan.
Satu migrasi. 89 index. Saya menyebutnya "The Index Apocalypse."
```sql
-- Migrasi Index Apocalypse
-- Tujuan: Perbaiki performa dengan mengindex semua kolom foreign key
-- Tabel organization-scoped
CREATE INDEX idx_users_org_id ON users(org_id);
CREATE INDEX idx_resources_org_id ON resources(org_id);
CREATE INDEX idx_documents_org_id ON documents(org_id);
-- Tabel resource-scoped
CREATE INDEX idx_user_data_resource_id ON user_data(resource_id);
CREATE INDEX idx_tasks_resource_id ON tasks(resource_id);
-- Index multi-kolom untuk pola query umum
CREATE INDEX idx_user_data_org_resource ON user_data(org_id, resource_id);
-- Query soft-delete
CREATE INDEX idx_resources_org_archived ON resources(org_id, archived_at);
-- Total: 89 index di 32 tabel
```
Saya menjalankan migrasi. Tangan saya benar-benar melayang di atas tombol Enter sejenak, bertanya-tanya apakah saya akan membuat segalanya lebih buruk. T.T
Waktu migrasi: 4 menit.
---
Hasilnya: Dari Bencana ke Siap Peluncuran
Sebelum (Tanpa Index)
```sql
Seq Scan on user_data
Execution Time: 843.271 ms
Rows Removed by Filter: 12,834
```
Sesudah (Dengan Index)
```sql
Index Scan using idx_user_data_org_resource
Execution Time: 2.847 ms
```
843ms → 2.8ms
Itu 296x lebih cepat untuk satu query.
Dampak dunia nyata:
- Load dashboard: 2-3 detik → 120ms
- List resource: 1+ detik → 45ms
- Query data: 850ms → 12ms
Perbaikan rata-rata: 20-40x lebih cepat. Beberapa join kompleks dengan multiple foreign key? 100x lebih cepat.
Perbedaannya siang dan malam. Platform bergerak dari "Apakah ini rusak?" ke "Wah, ini cepat." :D
---
Kenapa Ini Penting untuk SaaS Multi-Tenant
Kalau kamu membangun SaaS multi-tenant dengan Row-Level Security, ini sangat kritis.
RLS policies berjalan di setiap query:
```sql
CREATE POLICY resources_org_isolation ON resources
USING (org_id = get_user_org_id());
```
Tanpa index di `org_id`, policy ini memaksa sequential scan di setiap query. Bahkan statement SELECT sederhana jadi lambat.
Biayanya: Isolasi multi-tenant tanpa index = infrastruktur mahal + pengalaman pengguna lambat = churn.
Pelajaran bisnisnya: Fitur keamanan tanpa optimisasi performa sebenarnya tidak aman — karena pengguna akan pergi sebelum mereka merasakan keamananmu.
---
Polanya: Apa yang Perlu Di-Index
Setelah pelajaran mahal ini, ini checklist saya untuk setiap tabel baru:
Selalu index ini:
1. Kolom foreign key - Setiap satu
2. Kolom di RLS policies - Terutama `org_id` di aplikasi multi-tenant
3. Kolom di WHERE clause - Kalau sering difilter, index
4. Kolom di ORDER BY - Sorting tanpa index membunuh performa
Pertimbangkan index multi-kolom:
```sql
-- Untuk: WHERE org_id = X AND resource_id = Y
CREATE INDEX idx_table_org_resource ON table(org_id, resource_id);
```
Jangan over-index: Setiap index mengorbankan penyimpanan, performa write, dan maintenance. Rule of thumb: Kalau muncul di WHERE/JOIN/ORDER BY secara sering, index. Kalau tidak, jangan.
---
Cara Mengecek Database-mu Sekarang
Jalankan query diagnostik ini:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
Kalau dapat hasil, kamu punya index yang hilang.
---
Pelajaran yang Dipetik
1. Asumsi Postgres itu berbahaya — Saya mengasumsikan foreign key ter-index. Ternyata tidak. Selalu verifikasi.
2. RLS butuh index — Row-Level Security tanpa index sebenarnya lebih buruk dari tidak berguna. Menambah overhead ke setiap query.
3. Multi-tenant = index org_id di mana-mana — Di arsitektur multi-tenant, `org_id` muncul di hampir setiap query. Index di setiap tabel. Tanpa pengecualian.
4. Performa adalah masalah bisnis — Pengguna tidak peduli SQL-mu secara teknis benar kalau halaman butuh 3 detik untuk dimuat. Query cepat = konversi lebih baik = revenue.
5. Tambah index lebih awal — Menambah index ke tabel kosong itu instan. Menambahnya ke tabel dengan jutaan baris butuh berjam-jam dan mengunci tabel.
6. Ukur semuanya — Gunakan `EXPLAIN ANALYZE` sebelum dan sesudah. Buktikan perbaikan dengan data.
---
Pikiran Akhir
89 index yang hilang. 2 minggu debugging. Satu query diagnostik untuk menemukan semuanya. 4 menit untuk memperbaiki.
Ironinya? Postgres memberi semua tool untuk mendiagnosis ini. Saya hanya tidak tahu harus melihat. Sekarang setiap tabel yang saya buat langsung di-index — foreign key, kolom RLS, field sort, semuanya.
Migrasi 4 menit yang menambah 89 index menghemat berminggu-minggu kerja optimisasi di masa depan dan mencegah bencana peluncuran.
Untuk founder: Performa bukan hanya masalah engineering. Ini masalah konversi, retensi, dan kredibilitas. Database "secara teknis benar" yang terlalu lambat untuk digunakan? Itu mengorbankan pelangganmu.
Untuk engineer: Postgres tidak akan auto-index foreign key-mu. Tapi kamu harus. Pengguna (dan CPU database-mu) akan berterima kasih.
Pernahkah kamu punya momen "secara teknis benar tapi menyakitkan lambat" dengan database-mu? Apa perbaikannya — dan berapa lama kamu menemukannya?
Salam,
Chandler
Seri arsitektur STRAŦUM: Krisis performa ini adalah potongan terakhir dari puzzle multi-tenancy yang dimulai dengan membangun multi-tenancy di Hari 2, berlanjut dengan rebuild schema lengkap di Hari 67, dan termasuk memperbaiki 31 blank screen dari konteks navigasi yang hilang.
---
Membangun SaaS multi-tenant dengan Postgres? Saya belajar pelajaran ini dengan cara sulit agar kamu tidak perlu. Cek database-mu sekarang sebelum penggunamu menyadari.
Minta akses alpha di https://stratum.chandlernguyen.com/request-invitation
---
Masih belajar bahwa "berfungsi" dan "berfungsi cepat" adalah tujuan yang berbeda — dan hanya satu yang bisa di-ship.
---





