Postgres index bloat detection + REINDEX
Deteksi index bloat di Postgres dan REINDEX CONCURRENTLY tanpa downtime. Performance regression diam-diam yang sering kelewat.
Dipublikasikan 15 Juli 2026
Postgres “kok lambat ya?” padahal dulu cepat. Bisa jadi index bloat — UPDATE/DELETE banyak bikin index gendut, query latency naik diam-diam. Snippet ini query deteksi bloat (yang akurat, bukan estimate kasar) + REINDEX CONCURRENTLY untuk fix tanpa lock table.
Kode
-- ==========================================
-- Query 1: list top index dengan estimated bloat
-- Pakai formula bloat-check yang sudah well-known
-- ==========================================
WITH btree_index_atts AS (
SELECT
nspname, relname, reltuples, relpages, indrelid, relam,
regexp_split_to_table(indkey::TEXT, ' ')::SMALLINT AS attnum,
indexrelid AS index_oid
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
JOIN pg_am ON pg_class.relam = pg_am.oid
WHERE pg_am.amname = 'btree' AND nspname NOT IN ('pg_catalog', 'information_schema')
),
index_item_sizes AS (
SELECT
ind_atts.nspname,
ind_atts.relname AS index_name,
ind_atts.reltuples,
ind_atts.relpages,
ind_atts.relam,
indrelid AS table_oid,
index_oid,
current_setting('block_size')::NUMERIC AS bs,
8 AS maxalign,
24 AS pagehdr,
/* per-tuple header size */
CASE WHEN MAX(COALESCE(pg_stats.null_frac, 0)) = 0
THEN 2 ELSE 6 END AS index_tuple_hdr,
SUM((1 - COALESCE(pg_stats.null_frac, 0))
* COALESCE(pg_stats.avg_width, 1024))::INT AS nulldatawidth
FROM pg_attribute
JOIN btree_index_atts AS ind_atts
ON pg_attribute.attrelid = ind_atts.indexrelid
AND pg_attribute.attnum = ind_atts.attnum
LEFT JOIN pg_stats ON pg_stats.schemaname = ind_atts.nspname
AND ((pg_stats.tablename = ind_atts.relname AND pg_stats.attname = pg_get_indexdef(ind_atts.index_oid, ind_atts.attnum, TRUE))
OR (pg_stats.tablename = ind_atts.relname AND pg_stats.attname = ind_atts.attnum::TEXT))
WHERE pg_attribute.attnum > 0
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9
),
index_aligned AS (
SELECT
maxalign, bs, nspname, index_name, reltuples, relpages,
relam, table_oid, index_oid,
(5 + index_tuple_hdr + nulldatawidth +
CASE WHEN (index_tuple_hdr + nulldatawidth) % maxalign = 0 THEN 0
ELSE maxalign - (index_tuple_hdr + nulldatawidth) % maxalign END
)::NUMERIC AS nulldatahdrwidth
FROM index_item_sizes
),
otta_calc AS (
SELECT
bs, nspname, table_oid, index_oid, index_name, relpages,
COALESCE(CEIL((reltuples * (4 + nulldatahdrwidth)) / (bs - 24::FLOAT)), 0) AS otta
FROM index_aligned
)
SELECT
nspname AS schema,
index_name,
pg_size_pretty(bs * relpages::BIGINT) AS index_size,
pg_size_pretty(bs * otta::BIGINT) AS estimated_optimal,
CASE WHEN relpages::BIGINT = 0 THEN 0
ELSE ROUND((100 * (relpages - otta) / relpages)::NUMERIC, 2)
END AS bloat_pct,
CASE WHEN relpages < otta THEN '0 bytes'
ELSE pg_size_pretty(bs * (relpages - otta)::BIGINT)
END AS waste_bytes
FROM otta_calc
WHERE relpages > 100 -- skip index kecil
ORDER BY bs * (relpages - otta)::BIGINT DESC
LIMIT 20;
-- ==========================================
-- Query 2: pgstattuple — AKURAT (butuh extension)
-- Lebih lama tapi exact, recommended untuk decision REINDEX
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
schemaname AS schema,
tablename AS table_name,
indexname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
ROUND((1 - leaf_fragmentation/100.0)::NUMERIC, 2) AS leaf_density,
ROUND(leaf_fragmentation::NUMERIC, 2) AS bloat_pct,
pg_size_pretty(
(pg_relation_size(indexrelid) * leaf_fragmentation / 100)::BIGINT
) AS estimated_bloat_bytes
FROM (
SELECT
s.schemaname,
s.tablename,
i.indexrelname AS indexname,
i.indexrelid,
(pgstatindex(i.indexrelid::regclass::text)).leaf_fragmentation
FROM pg_stat_user_indexes i
JOIN pg_stat_user_tables s ON s.relid = i.relid
WHERE pg_relation_size(i.indexrelid) > 10 * 1024 * 1024 -- > 10MB
) sub
WHERE leaf_fragmentation > 20
ORDER BY pg_relation_size(indexrelid) * leaf_fragmentation DESC
LIMIT 20;
-- ==========================================
-- Query 3: dead tuple ratio (untuk VACUUM kebutuhan)
-- ==========================================
SELECT
schemaname AS schema,
relname AS table_name,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
CASE WHEN n_live_tup + n_dead_tup = 0 THEN 0
ELSE ROUND(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 2)
END AS dead_pct,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
-- ==========================================
-- Action: REINDEX CONCURRENTLY (tanpa lock)
-- ==========================================
-- Single index — pakai CONCURRENTLY supaya tidak block write
REINDEX INDEX CONCURRENTLY public.idx_pesanan_status_kota_created;
-- Whole table (semua index pada table)
REINDEX TABLE CONCURRENTLY public.pesanan;
-- Whole database (long, hati-hati di prod)
-- REINDEX DATABASE CONCURRENTLY tokopedia_prod;
-- Cek progress (PG 12+)
SELECT
pid, phase, command,
blocks_done, blocks_total,
ROUND(100.0 * blocks_done / NULLIF(blocks_total, 0), 2) AS pct,
tuples_done, tuples_total
FROM pg_stat_progress_create_index;
-- ==========================================
-- Cleanup invalid index (kalau REINDEX CONCURRENTLY interrupt)
-- ==========================================
-- Cek invalid index dulu
SELECT
n.nspname AS schema, c.relname AS index_name,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE i.indisvalid = false;
-- Hapus invalid index (biasanya ada nama "_ccnew" atau "_ccold")
DROP INDEX CONCURRENTLY IF EXISTS public.idx_pesanan_status_kota_created_ccnew;
Pemakaian
# Cron script — cek bloat mingguan
psql -d tokopedia_prod -f /opt/maintenance/check_bloat.sql > /var/log/bloat_report.log
# Alert via Slack kalau ada index > 50% bloat
THRESHOLD=50
RESULT=$(psql -t -d tokopedia_prod -c "
SELECT index_name FROM ... WHERE bloat_pct > $THRESHOLD;
")
if [ -n "$RESULT" ]; then
curl -X POST -H 'Content-Type: application/json' \
-d "{\"text\":\":warning: Index bloat: $RESULT\"}" \
"$SLACK_WEBHOOK"
fi
-- Workflow REINDEX safe di production:
-- 1. Verify pakai pgstattuple (akurat)
SELECT * FROM pgstatindex('public.idx_pesanan_status_kota_created');
-- leaf_fragmentation > 30% → REINDEX
-- 2. Cek koneksi yang akan kena block
SELECT pid, usename, application_name, state, query_start, query
FROM pg_stat_activity
WHERE state != 'idle' AND query LIKE '%idx_pesanan_status_kota_created%';
-- 3. REINDEX CONCURRENTLY
\timing
REINDEX INDEX CONCURRENTLY public.idx_pesanan_status_kota_created;
-- 4. Verify ukuran turun + masih valid
SELECT
indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS new_size,
indisvalid AS valid
FROM pg_index
WHERE indexrelid = 'public.idx_pesanan_status_kota_created'::regclass;
-- 5. ANALYZE table supaya planner update
ANALYZE pesanan;
# Untuk database besar — REINDEX semua index bloated dengan delay
psql -d tokopedia_prod <<'SQL' | while read idx; do
echo "REINDEX $idx ..."
psql -d tokopedia_prod -c "REINDEX INDEX CONCURRENTLY $idx;" \
|| echo " FAIL: $idx"
sleep 30 # rest antara reindex supaya tidak overload
done
SELECT format('%I.%I', n.nspname, c.relname)
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE (pgstatindex(c.oid::regclass::text)).leaf_fragmentation > 30
AND pg_relation_size(i.indexrelid) > 100 * 1024 * 1024;
SQL
Kapan dipakai
- Postgres database yang sudah beroperasi > 6 bulan dengan banyak UPDATE/DELETE.
- Query latency naik tanpa ada perubahan schema atau data growth signifikan.
- Setelah migration besar (bulk update jutaan row).
- Maintenance window quarterly untuk healthy DB.
Catatan
- REINDEX CONCURRENTLY Postgres 12+. Tidak block write tapi butuh disk space sementara 2x ukuran index. Pastikan disk free cukup.
- Versi lama harus pakai pattern: CREATE INDEX CONCURRENTLY ix_new + DROP INDEX ix_old + RENAME. Manual tapi works di PG 11.
- pgstattuple akurat tapi lambat — locking ringan, tapi full scan index. Jalankan di off-peak.
- autovacuum settings — bloat = symptom autovacuum kurang aggressive. Tune
autovacuum_vacuum_scale_factordari 0.2 ke 0.05 untuk tabel hot. - HOT update — kalau index tidak include kolom yang di-UPDATE, Postgres bisa HOT update tanpa update index. Design schema dengan ini in mind.
- invalid index dari REINDEX CONCURRENTLY interrupt — drop manual, jangan dibiarkan (tidak dipakai planner tapi makan disk).
- bloat di TOAST — tabel besar dengan kolom TEXT/JSONB juga bisa bloat di TOAST. Pakai
pg_total_relation_sizeuntuk full picture.
REINDEX bukan silver bullet. Kalau aplikasi pattern-nya banyak UPDATE kolom yang ada di index, bloat akan come back. Refactor schema lebih impactful — separate hot field ke tabel terpisah.
# tags
postgresindexbloatvacuumperformance
Ditulis oleh Asti Larasati · 15 Juli 2026