πŸ—„οΈ Database Internals: Indexing, MVCC & Query Planning

Vault udah punya postgresql-administrasi-backup dan postgresql-performance-triage β€” dua-duanya fokus ke operasional: backup, restore, troubleshooting slow query. Tapi belum ada yang bedah apa yang terjadi di dalam database ketika lo jalanin CREATE INDEX, UPDATE, atau EXPLAIN ANALYZE. Catatan ini adalah layer fundamental yang ngejelasin kenapa index B-Tree cocok untuk range query tapi jelek untuk JSONB, kenapa UPDATE lebih mahal dari INSERT (akibat MVCC), dan bagaimana query planner milih antara Nested Loop, Hash Join, atau Merge Join. Tanpa ini, lo cuma bisa bilang β€œquery lambat, bikin index” tanpa ngerti index mana yang tepat untuk workload lo.

Posisi di Vault

Ini adalah teori di belakang postgresql-administrasi-backup dan postgresql-performance-triage. Baca ini untuk paham kenapa operasional PostgreSQL bekerja seperti itu. Juga terhubung dengan ddia-kleppmann (Part II β€” storage & retrieval) dan data-engineering (pipeline data).


Daftar Isi


1. Storage Models β€” Heap vs LSM vs B-Tree

1.1 Heap Storage (PostgreSQL)

PostgreSQL menggunakan heap storage β€” data disimpan dalam blok (8KB default), tanpa urutan tertentu. Index adalah struktur terpisah yang menunjuk ke heap:

Relation (table):
  β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”
  β”‚Block0β”‚Block1β”‚Block2β”‚Block3β”‚Block4β”‚Block5β”‚...
  β””β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”¬β”€β”€β”€β”˜
     β”‚      β”‚      β”‚      β”‚      β”‚      β”‚
  β”Œβ”€β”€β–Όβ”€β”€β”β”Œβ”€β–Όβ”€β”€β”€β”β”Œβ”€β–Όβ”€β”€β”€β”β”Œβ”€β–Όβ”€β”€β”€β”β”Œβ”€β–Όβ”€β”€β”€β”β”Œβ”€β–Όβ”€β”€β”€β”
  β”‚Row1 β”‚β”‚Row2 β”‚β”‚Row3 β”‚β”‚Row4 β”‚β”‚Row5 β”‚β”‚Row6 β”‚
  β”‚Row2 β”‚β”‚Row5 β”‚β”‚     β”‚β”‚     β”‚β”‚     β”‚β”‚     β”‚  ← bisa ada free space
  β”‚     β”‚β”‚     β”‚β”‚     β”‚β”‚     β”‚β”‚     β”‚β”‚     β”‚
  β””β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”˜

Index (B-Tree):
  [Key β†’ (Block, Offset)]
  [1 β†’ (0,0)] [2 β†’ (0,1)] [3 β†’ (1,0)] [4 β†’ (1,1)] [5 β†’ (2,0)] [6 β†’ (2,1)]

Konsekuensi:

  • INSERT cepat β€” tulis di blok mana aja yang ada free space
  • UPDATE mahal β€” mark row as dead (xmax), tulis row baru di blok lain
  • Sequential scan β€” baca semua blok, filter yang visible (via MVCC)
  • Index scan β€” cari di index, fetch dari heap via TID (tuple ID = Block + Offset)

1.2 LSM-Tree (LevelDB, RocksDB, Cassandra)

LSM-Tree = Log-Structured Merge Tree. Write-optimized dengan mengorbankan read performance:

Write Path:
  MemTable (in-memory, sorted) β†’ immutable β†’ flush to SSTable Level 0
                                                      ↓
                                        Levels 1, 2, 3... (major compaction)

Read Path:
  Check MemTable β†’ Level 0 SSTables β†’ Level 1, 2, 3... (merge)

Compaction:
  Minor: flush MemTable ke SSTable (cepat)
  Major: merge SSTable level N dengan N+1 (lambat, I/O intensive)
AspekB-Tree (PostgreSQL)LSM-Tree (Cassandra)
Write throughput🟑 Random I/O ke heap🟒 Sequential write ke SSTable
Read (point lookup)🟒 O(log N) via index🟑 Check multiple SSTables
Read (range scan)🟒 B-Tree leaf node linked list🟑 Bloom filter + merge
Space amplification🟑 Pages dengan dead tuples (bloat)🟒 SSTable immutable, compacted
Write amplification🟒 Minimal (update langsung)🟑 Compaction I/O

1.3 Kapan Pilih Yang Mana?

B-Tree β†’ OLTP, banyak UPDATE/DELETE, range query penting
LSM-Tree β†’ Write-heavy workloads, time-series data, log ingestion
Columnar β†’ Analytical queries (OLAP), aggregasi, read-only historis

2. Indexing Deep Dive β€” B-Tree, GiST, GIN, BRIN

2.1 B-Tree β€” Default PostgreSQL Index

Struktur:

Root Page (1 page):
  [50, 100]
  /    |    \
Internal Pages:
  [10, 30]  [60, 80]  [110, 130]
  /    |    /   |    /    |    \
Leaf Pages (doubly linked):
  [1,5,10] ↔ [15,20,25,30] ↔ [35,40,45,50] ↔ ...

Karakteristik penting:

  • Height = 3-4 untuk tabel dengan miliaran baris (karena branching factor tinggi)
  • Leaf nodes adalah doubly linked list β€” range scan pindah dari leaf ke leaf via pointer (gak perlu balik ke root)
  • Page size: 8KB default. Branching factor β‰ˆ (page_size - header) / (key_size + pointer_size) β‰ˆ 200-300 untuk key 32 byte
  • Write amplification: INSERT trigger page split jika leaf page penuh. Split = 1 page β†’ 2 pages + update parent pointer (β‰ˆ 3-4 pages I/O)
-- Melihat struktur index
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL,
    name TEXT
);
 
-- Index B-Tree otomatis untuk PRIMARY KEY
-- Index tambahan:
CREATE INDEX idx_users_email ON users(email);
 
-- Cek ukuran index
SELECT
    pg_size_pretty(pg_relation_size('users')) AS table_size,
    pg_size_pretty(pg_relation_size('users_pkey')) AS pk_index_size,
    pg_size_pretty(pg_relation_size('idx_users_email')) AS email_index_size;
 
-- Melihat page-level statistik
SELECT * FROM pageinspect.bt_metap('users_pkey');
SELECT * FROM pageinspect.bt_page_stats('users_pkey', 1);

2.2 Composite Index β€” Multicolumn

CREATE INDEX idx_users_lastname_firstname ON users(last_name, first_name);
 
-- Query yang bisa pake index ini:
SELECT * FROM users WHERE last_name = 'Smith';                    -- βœ… Prefix
SELECT * FROM users WHERE last_name = 'Smith' AND first_name = 'J'; -- βœ… Full
SELECT * FROM users WHERE last_name LIKE 'Smi%';                  -- βœ… Range pada prefix
SELECT * FROM users WHERE first_name = 'J';                       -- ❌ Bukan prefix (gak efisien)
 
-- Index on expression:
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'admin@example.com';     -- βœ… Pake index

Ponytail: Index last_name, first_name, middle_name bisa serve query yang filter di last_name, last_name + first_name, atau ketiganya. Tapi gak bisa serve yang filter first_name aja atau first_name + middle_name. Pahami leftmost prefix rule.

2.3 Partial Index β€” Index Hanya untuk Data Tertentu

-- Index yang cuma mencakup baris dengan status = 'active'
CREATE INDEX idx_orders_active ON orders(order_date)
    WHERE status = 'active';
 
-- Berguna untuk:
-- 1. 99% query hanya akses orders active
-- 2. Index jadi jauh lebih kecil
-- 3. INSERT/UPDATE ke orders non-active gak kena index maintenance
 
-- Query yang bisa:
SELECT * FROM orders WHERE status = 'active' AND order_date > '2026-01-01';  -- βœ…
SELECT * FROM orders WHERE status = 'pending';                                -- ❌ (status != 'active')

2.4 GiST, GIN, BRIN β€” Specialized Index

GiST (Generalized Search Tree):

-- Full-text search
CREATE INDEX idx_articles_fts ON articles USING GIN(to_tsvector('english', body));
SELECT * FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('english', 'database & indexing');
 
-- Geometri (PostGIS)
CREATE INDEX idx_locations ON places USING GIST(location);
SELECT * FROM places
WHERE location <@ box '(10,10,20,20)';  -- Points within a box

GIN (Generalized Inverted Index):

-- JSONB indexing
CREATE TABLE events (data JSONB);
CREATE INDEX idx_events_gin ON events USING GIN(data);
 
-- Query yang bisa pake GIN index:
SELECT * FROM events WHERE data @> '{"type": "login"}';   -- βœ… contains
SELECT * FROM events WHERE data ? 'ip_address';             -- βœ… key exists
SELECT * FROM events WHERE data ?| ARRAY['email', 'phone']; -- βœ… any key exists
 
-- Lebih efisien dengan jsonb_path_ops:
CREATE INDEX idx_events_gin_ops ON events USING GIN(data jsonb_path_ops);
-- 2-3x lebih kecil, tapi hanya support @> operator

BRIN (Block Range Index) β€” untuk time-series:

-- BRIN index β€” ideal untuk data yang berurutan (time-series)
CREATE INDEX idx_logs_created_at ON logs USING BRIN(created_at)
    WITH (pages_per_range = 32);  -- 32 blocks per range
 
-- Ukuran: BRIN β‰ˆ 0.1% dari ukuran table
-- B-Tree β‰ˆ 30% dari ukuran table
-- Untuk logs table 100GB: BRIN = 100MB vs B-Tree = 30GB
 
-- Query yang efisien dengan BRIN:
SELECT * FROM logs WHERE created_at >= '2026-07-01' AND created_at < '2026-07-02';

2.5 Perbandingan Index Types

TypeUkuranWrite OverheadQuery TypesUse Case
B-Tree30% dari tableModerate=, <, >, BETWEEN, ORDER BY, LIKE β€˜prefix%β€˜General purpose (90% kasus)
HashKecilRendah= onlySama jarang dipake di PG
GiSTBesarTinggiFull-text, geometri, range overlapSpatial, FTS
GINBesarTinggiJSONB contains, array overlap, tsvectorJSONB, full-text, arrays
BRINSangat kecil (0.1%)Sangat rendahRange scan (data correlated)Time-series, logs

3. Query Planning & Execution

3.1 Query Lifecycle

SQL Query β†’ Parser β†’ Rewriter β†’ Planner β†’ Executor β†’ Result
              ↓          ↓          ↓         ↓
           Parse tree  Rewritten   Plan tree  Execution
                        tree        (costs)   (loops)

3.2 EXPLAIN Plan β€” Cara Baca

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders
    JOIN customers ON orders.customer_id = customers.id
    WHERE orders.total > 1000
    ORDER BY orders.created_at DESC
    LIMIT 10;
 
-- Output (simplified):
--                                                                      QUERY PLAN
-- ────────────────────────────────────────────────────────────────────────────────────────────
-- Limit  (cost=12.34..45.67 rows=10 width=120) (actual time=0.123..0.456 rows=10 loops=1)
--   ->  Sort  (cost=12.34..45.67 rows=1000 width=120) (actual time=0.123..0.456 rows=10 loops=1)
--         Sort Key: orders.created_at DESC
--         Sort Method: top-N heapsort  Memory: 25kB
--         ->  Hash Join  (cost=8.90..42.10 rows=1000 width=120) (actual time=0.050..0.200 rows=1000 loops=1)
--               Hash Cond: (orders.customer_id = customers.id)
--               ->  Seq Scan on orders  (cost=0.00..30.40 rows=1000 width=80) (actual time=0.010..0.100 rows=1000 loops=1)
--                     Filter: (total > 1000)
--                     Rows Removed by Filter: 4000
--               ->  Hash  (cost=6.40..6.40 rows=200 width=40) (actual time=0.020..0.020 rows=200 loops=1)
--                     ->  Seq Scan on customers  (cost=0.00..6.40 rows=200 width=40) (actual time=0.005..0.015 rows=200 loops=1)

Cara membaca:

  • Baca dari dalam ke luar (children β†’ parent)
  • cost=12.34..45.67 = estimated start-up..total cost (unit = arbitrary, biasanya page I/O + CPU)
  • actual time=0.123..0.456 = real execution time (ms)
  • rows=10 vs rows=1000 β€” estimated vs actual. Gap besar = planner statistics outdated
  • loops=1 β€” berapa kali node ini di-execute (penting untuk Nested Loop!)
  • Buffers: shared hit=42 read=8 β€” page cache hit vs disk read

3.3 Plan Node Types

NodeKetika Muncul
Seq ScanFull table scan β€” biasanya karena gak ada index, atau selectivity terlalu rendah (< 5%)
Index ScanPake index β†’ fetch dari heap. Cepat kalo selectivity tinggi
Index Only ScanSemua data ada di index (covering index) β€” tanpa fetch heap. Paling cepat
Bitmap Heap ScanKombinasi index + bitmap. Berguna untuk kombinasi beberapa index
Nested LoopUntuk join kecil. O(n * m) β€” bagus kalo salah satu tabel kecil
Hash JoinHash satu tabel β†’ probe. O(n + m) β€” bagus untuk equi-join
Merge JoinSort + merge. O(n log n + m log m) β€” bagus untuk data yang sudah sorted
SortORDER BY / DISTINCT / Merge Join

3.4 Statistik Planner

-- Cek statistik tabel
SELECT
    relname,
    n_live_tup,        -- jumlah row visible
    n_dead_tup,        -- jumlah row dead (bloat indicator)
    last_analyze,      -- kapan terakhir ANALYZE
    last_autovacuum,   -- kapan terakhir autovacuum
    seq_scan,          -- berapa kali sequential scan
    seq_tup_read,      -- total row dibaca via seq scan
    idx_scan,          -- berapa kali index scan
    idx_tup_fetch      -- total row diambil via index
FROM pg_stat_user_tables
WHERE relname = 'orders';
 
-- Update statistik
ANALYZE orders;
 
-- Set target statistik (default 100)
ALTER TABLE orders ALTER COLUMN total SET STATISTICS 1000;
-- Lebih tinggi = lebih akurat (tapi ANALYZE lebih lambat)

4. Join Strategies β€” Nested Loop vs Hash Join vs Merge Join

4.1 Nested Loop Join

for each row in outer_table:
    for each row in inner_table:
        if match condition:
            emit row
 
Cost: O(n * m) β€” worst case
      O(n * log m) β€” dengan index di inner table

Kapan efektif:

  • Satu tabel sangat kecil (< 100 baris)
  • Ada index unik di inner table yang bisa di-index lookup
  • Query dengan LIMIT yang kecil
-- PostgreSQL akan pake Nested Loop secara otomatis
-- Contoh: 10 customers Γ— 1000 orders (with index on orders.customer_id)
EXPLAIN (ANALYZE)
SELECT * FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.id = 42;
-- Output: Nested Loop β†’ Index Scan on orders (by customer_id)

4.2 Hash Join

1. Hash inner table β†’ build hash table (in memory)
2. For each row in outer table:
       probe hash table β†’ if match, emit row
 
Cost: O(n + m) β€” linear, more predictable
      Memory: cukup untuk hold hash table di memory

Kapan efektif:

  • Equi-join (=) β€” tidak bekerja untuk range join
  • Salah satu tabel cukup kecil untuk hash table di memory
  • Data tidak sorted (tidak perlu expensive sort)
-- Pilih hash join secara eksplisit (jarang perlu)
SET enable_nestloop = off;
EXPLAIN SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id;

4.3 Merge Join

1. Sort both tables by join key
2. Merge like merge sort: iterate both, advance pointer of smaller
   β†’ single pass if both already sorted
 
Cost: O(n log n + m log m) β€” sorting dominant
      O(n + m) β€” jika sudah sorted

Kapan efektif:

  • Data sudah sorted (misal oleh ORDER BY yang sama)
  • Range join (> , <, BETWEEN)
  • Large tables yang bisa di-sort dalam memory

4.4 Perbandingan Join Strategies

JoinBest CaseWorst CaseMemoryUse Case
Nested LoopO(n) via indexO(n*m)MinimalOne small table, index available
Hash JoinO(n+m)O(n+m) + disk spillingHash table in memoryEqui-join, mid-size tables
Merge JoinO(n+m) pre-sortedO(n log n + m log m)Sort bufferRange join, large sorted tables

5. MVCC β€” Mekanisme Concurrency di PostgreSQL

5.1 Bagaimana MVCC Bekerja

PostgreSQL MVCC (Multiversion Concurrency Control) = setiap transaksi melihat snapshot data di titik waktu tertentu. Bukan β€œlock data”, tapi β€œcreate version baru”:

Heap Page:
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ [Tuple 1] xmin=100 xmax=200 [data: "Alice"]      β”‚ ← visible untuk txid 100-199
  β”‚ [Tuple 2] xmin=101 xmax=0    [data: "Bob"]        β”‚ ← visible dari txid 101+
  β”‚ [Tuple 3] xmin=200 xmax=0    [data: "Charlie"]    β”‚ ← visible dari txid 200+ (baru di-INSERT)
  β”‚ [Tuple 4] xmin=201 xmax=202  [data: "Diana"]      β”‚ ← visible untuk txid 201 saja
  β”‚ [Free Space]                                       β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Bagaimana UPDATE bekerja:

Step 1: UPDATE users SET name = 'Alice Updated' WHERE id = 1;
  - Original row: xmin=100, xmax=CURRENT_TXID  ← "dead" untuk txid berikutnya
  - New row:      xmin=CURRENT_TXID, xmax=0     ← visible hanya untuk txid ini sampai commit

Step 2: COMMIT;
  - Snapshot baru lihat row baru
  - Row lama tetap ada (dead tuple) sampai VACUUM membersihkannya

5.2 Snapshot Isolation

Setiap transaksi mendapatkan snapshot β€” daftar transaksi aktif saat first query:

-- Lihat snapshot yang aktif
SELECT txid_current(), txid_snapshot_xmin(txid_current_snapshot()),
       txid_snapshot_xmax(txid_current_snapshot());

Visibility rules:

  • xmin < txid_snapshot_xmin β†’ visible (sudah committed sebelum snapshot)
  • xmin in snapshot β†’ NOT visible (masih running saat snapshot)
  • xmax < txid_snapshot_xmin β†’ deleted (tidak visible)
  • xmax in snapshot β†’ visible (delete belum committed)

5.3 Hot Standby & Conflicts

PostgreSQL streaming replication menggunakan MVCC untuk Hot Standby:

Primary: INSERT INTO users VALUES (1, 'Alice');  -- txid 100
Secondary: SELECT * FROM users WHERE id = 1;      -- txid 100 masih in-progress β†’ skip (gak visible)

Setelah txid 100 commit di primary:
Secondary: SELECT * FROM users WHERE id = 1;      β†’ visible (xmin < snapshot)

Conflict scenarios:

  • VACUUM di primary β†’ remove dead tuples
  • VACUUM di secondary β†’ wait for queries to finish
  • Long-running query di secondary = blokir vacuum

6. Isolation Levels & Race Conditions

6.1 Definisi Isolation Levels

LevelDirty ReadNon-Repeatable ReadPhantom ReadSerialization Anomaly
Read UncommittedMungkinMungkinMungkinMungkin
Read Committedβœ… TidakMungkinMungkinMungkin
Repeatable Readβœ… Tidakβœ… TidakMungkin di SQL std., βœ… Tidak di PGMungkin
Serializableβœ… Tidakβœ… Tidakβœ… Tidakβœ… Tidak

Catatan PostgreSQL: Read Uncommitted = Read Committed (PG gak support dirty read).

6.2 Race Conditions β€” Contoh Real

Lost Update:

-- Session A                          -- Session B
BEGIN;                                BEGIN;
SELECT balance FROM accounts          SELECT balance FROM accounts
  WHERE id = 1; -- balance = 100        WHERE id = 1; -- balance = 100
                                      UPDATE accounts SET balance = 200
                                        WHERE id = 1;
UPDATE accounts SET balance = 150     COMMIT;
  WHERE id = 1; -- based on old 100!
COMMIT;
-- Result: balance = 150 (kehilangan update B β†’ 200)

Solusi dengan SELECT ... FOR UPDATE:

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- Lock baris!
-- Session B akan menunggu sampai session A commit
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;

Write Skew (Paling Subtle):

-- Dua dokter on-call, aturan: minimal satu harus available
-- Session A                          -- Session B
BEGIN;                                BEGIN;
SELECT count(*) FROM on_call          SELECT count(*) FROM on_call
  WHERE available = true;               WHERE available = true;
  -- count = 2                           -- count = 2
UPDATE on_call SET available = false  UPDATE on_call SET available = false
  WHERE doctor_id = 1;                  WHERE doctor_id = 2;
COMMIT;                               COMMIT;
-- Result: no doctor available! (keduanya lihat count=2)

Solusi: Serializable isolation + retry logic:

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- Akan detect write skew β†’ salah satu transaksi dapat:
-- ERROR: could not serialize access due to read/write dependencies
-- Lalu retry

6.3 When to Use Which

IsolationLatencyCorrectnessUse Case
Read CommittedRendahLemahDashboard read-only, logs
Repeatable ReadSedangModerateReporting, analytics
SerializableTinggiGuaranteedFinancial, inventory, kuota

7. Vacuum, Bloat & Autovacuum Tuning

7.1 Dead Tuples & Bloat

Setiap UPDATE/DELETE meninggalkan dead tuple. Tanpa VACUUM, tabel membengkak:

Before VACUUM:
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ [Alice (dead)] [Bob (dead)]    β”‚
  β”‚ [Charlie] [Diana]              β”‚
  β”‚ [Eve (dead)]                   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  Table size: 4 pages (dengan 3 dead)

After VACUUM:
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ [Charlie] [Diana] [Free]       β”‚
  β”‚ [Free] [Free] [Free]           β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  Table size: 2 pages (space tidak dikembalikan ke OS!)

Space reclamation: VACUUM hanya menandai free space dalam table file. Ukuran file tidak berkurang. Gunakan VACUUM FULL (table-level lock) atau pg_repack untuk return space ke OS.

7.2 Autovacuum Configuration

-- Default settings (cek dengan SHOW)
SHOW autovacuum_vacuum_threshold;        -- 50 (base threshold)
SHOW autovacuum_vacuum_scale_factor;      -- 0.2 (20% dari row count)
SHOW autovacuum_vacuum_cost_limit;        -- -1 (pakai vacuum_cost_limit)
SHOW autovacuum_naptime;                  -- 1min
 
-- Untuk tabel besar, scale factor 0.2 = 20% dari 100M rows = 20M dead tuples baru trigger vacuum
-- Ini terlalu lambat! Sesuaikan per tabel:
 
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01,   -- 1%
                        autovacuum_vacuum_threshold = 1000,      -- base 1000
                        autovacuum_vacuum_cost_limit = 1000);    -- lebih agresif

7.3 Monitoring Bloat

-- Estimasi bloat per tabel
SELECT
    schemaname || '.' || relname AS table_name,
    pg_size_pretty(pg_relation_size(relid)) AS table_size,
    n_dead_tup,
    n_live_tup,
    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct,
    last_autovacuum,
    vacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
 
-- Tabel yang butuh VACUUM segera:
-- dead_pct > 20% dan n_dead_tup > 100000

8. Query Optimization Patterns

8.1 Index Maintenance

-- Rebuild index (tanpa lock, concurrent)
REINDEX INDEX CONCURRENTLY idx_orders_customer_id;
 
-- Cek index size vs table size
SELECT
    i.relname AS index_name,
    pg_size_pretty(pg_relation_size(i.oid)) AS index_size,
    pg_size_pretty(pg_relation_size(t.oid)) AS table_size,
    ROUND(100.0 * pg_relation_size(i.oid) / NULLIF(pg_relation_size(t.oid), 0), 2) AS ratio
FROM pg_class i
JOIN pg_index ix ON i.oid = ix.indexrelid
JOIN pg_class t ON ix.indrelid = t.oid
WHERE t.relname = 'orders';

8.2 Common Optimization Patterns

Pattern 1: Covering Index

-- Query: SELECT id, name, email FROM users WHERE status = 'active';
-- Create index yang cover semua kolom:
CREATE INDEX idx_users_status_covering ON users(status) INCLUDE (name, email);
-- Index Only Scan β†’ tanpa fetch heap

Pattern 2: Partial Index untuk Queue Pattern

-- Process queue: SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at;
CREATE INDEX idx_jobs_pending ON jobs(created_at) WHERE status = 'pending';
-- Index cuma berisi baris pending β†’ kecil, cepat

Pattern 3: Partitioning untuk Time-Series

-- Partition by month
CREATE TABLE orders_partitioned (LIKE orders INCLUDING ALL)
    PARTITION BY RANGE (created_at);
 
CREATE TABLE orders_2026_07 PARTITION OF orders_partitioned
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
 
CREATE TABLE orders_2026_08 PARTITION OF orders_partitioned
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
 
-- Query planner bisa pruning: hanya scan partition yang relevan
-- Berguna untuk data yang di-DELETE/di-ARCHIVE per bulan

8.3 Query Tuning Workflow

1. Temukan slow query (pg_stat_statements, slow query log)
2. EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
3. Identifikasi node paling mahal (Seq Scan? Nested Loop?)
4. Cek:
   - Apakah index ada? (missing index?)
   - Statistik outdated? (perlu ANALYZE?)
   - Parameter binding vs literal? (bind variable?)
5. Buat/ubah index
6. Test dengan EXPLAIN
7. Repeat

Gunakan [[postgresql-performance-triage]] untuk checklist troubleshooting.

9. Tools untuk Database Internals

ToolFungsi
pg_stat_statementsTracking query performance historis
pg_stat_user_tablesStatistik tabel (live/dead tuples, scan count)
pg_stat_user_indexesIndex usage (scan count, tuple fetch)
pageinspectMelihat page-level data (blok, tuple)
pg_buffercacheMelihat shared buffer cache
pg_repackOnline table rebuild (tanpa lock, Hapus bloat)
explain.depesz.comVisual EXPLAIN analyzer
pgMustardEXPLAIN analyzer dengan saran index
pganalyzePerformance monitoring SaaS

πŸ”— Koneksi ke Catatan Lain


βœ… Checklist

  • Paham B-Tree vs LSM-Tree trade-off buat workload yang beda
  • Bisa jelasin MVCC: bagaimana UPDATE, DELETE, dan VACUUM bekerja
  • Bisa baca EXPLAIN (ANALYZE) dan identifikasi bottleneck
  • Paham kapan Nested Loop better dari Hash Join dan sebaliknya
  • Bisa bedain index scan vs bitmap heap scan vs seq scan
  • Paham isolation levels + race conditions (lost update, write skew)
  • Bisa setup autovacuum tuning untuk tabel besar
  • Paham perbedaan B-Tree, GIN, GiST, BRIN β€” kapan pake yang mana
  • Bisa design composite index berdasarkan pola query

Roadmap Belajar

HARI 1: Storage & Indexing
  - Baca dokumen ini sampai selesai
  - Setup tabel dengan berbagai tipe index, bandingkan size
  - Test EXPLAIN untuk query sederhana vs kompleks

HARI 2: MVCC & Concurrency
  - Simulasi race condition dengan 2 session
  - Test isolation levels, lihat perbedaan behavior
  - Monitor dead tuples di pg_stat_user_tables

HARI 3: Query Optimization
  - Ambil slow query dari pg_stat_statements
  - Analisa dengan EXPLAIN, buat index yang tepat
  - Ukur improvement (execution time, buffer hits)

HARI 4: Advanced Indexing
  - Setup BRIN untuk time-series data
  - Test partial index untuk queue pattern
  - Setup GIN index untuk JSONB, test query performance

HARI 5: Bloat & Vacuum
  - Simulasi bloat dengan UPDATE/DELETE massal
  - Tuning autovacuum
  - Test pg_repack untuk online table rebuild

Bottom Line

Database internals bukan sekadar teori akademik β€” ini determines query performance di production. Index B-Tree yang salah bisa bikin query yang tadinya 1ms jadi 10 detik. MVCC bloat bisa bikin tabel 10GB membengkak jadi 100GB tanpa data baru. Query planner yang pake Nested Loop saat seharusnya Hash Join bisa bikin server collapse. Lo gak perlu jadi DBA, tapi lo perlu paham dasar-dasar ini agar bisa debugging slow query tanpa trial-and-error.

Lanjutan

Catatan ini belum mencakup distributed databases (Cassandra, CockroachDB, Spanner), vector databases untuk AI embeddings, dan query optimization untuk data warehouse (columnar, materialized aggregates). Baca ddia-kleppmann untuk distributed database theory.