Storage engines and indexes
Generated by Claude through GigAI (karthik446/gigai-learning-center 4c0cb514) on 2026-10-13.
Concept: Storage engines and indexes · also covers Relational and non-relational data stores, Data systems (schemas, indexing, partitioning), PostgreSQL · Lesson 3 of 31 in System design · version 1
Practice thisOn this page
A database's behaviour under load is decided by how it arranges data on disk and how it finds rows again. This lesson contrasts the two dominant engine families, explains how indexes and multiversion concurrency work, and shows how to verify what the engine actually does with a query.
Read more
- docPostgreSQL indexes (www.postgresql.org)
- docPostgreSQL MVCC and concurrency control (www.postgresql.org)
- docPostgreSQL using EXPLAIN (www.postgresql.org)
How it is done in practice
Teams pick the engine with the database: a relational B-tree store for transactional data, an LSM-based store where ingest volume dominates, and object storage for large immutable blobs. Indexes are added through reviewed migrations, built concurrently to avoid blocking writes, and justified with an EXPLAIN ANALYZE plan from production-like data. Slow query logs and statement statistics drive the tuning queue, while autovacuum behaviour, bloat and long-running transactions are watched as routine health signals.
Technologies
Diagram
flowchart TD W[Write] --> L[Write-ahead log] W --> M[Memtable in memory] M -->|flush when full| S0[Level 0 sorted files] S0 -->|compaction| S1[Level 1 sorted files] S1 -->|compaction| S2[Deeper levels] R[Read] --> M R --> B[Bloom filters and block cache] B --> S0 B --> S1 B --> S2
flowchart TD W[Write] --> L[Write-ahead log] W --> M[Memtable in memory] M -->|flush when full| S0[Level 0 sorted files] S0 -->|compaction| S1[Level 1 sorted files] S1 -->|compaction| S2[Deeper levels] R[Read] --> M R --> B[Bloom filters and block cache] B --> S0 B --> S1 B --> S2
Code
sqlCREATE INDEX CONCURRENTLY orders_customer_created_idx
ON orders (customer_id, created_at DESC);
CREATE INDEX CONCURRENTLY orders_pending_idx
ON orders (created_at)
WHERE status = 'pending';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
Subtopics
B-tree and LSM-tree engines
Update in place versus append and merge
A B-tree engine keeps sorted fixed-size pages and updates them in place, protected by a write-ahead log. Reads are predictable, needing a few page visits per lookup, while random writes dirty pages scattered across the disk. An LSM-tree engine such as RocksDB appends each write to a log and an in-memory memtable, flushes full memtables as immutable sorted files, and merges those files in background compaction. Writes become sequential and cheap, and compression is effective, but a read may consult several files, which bloom filters and block caches mitigate. The tradeoff is among write amplification, read amplification and space amplification, and compaction can compete with foreground traffic. Choose by workload: write-heavy ingestion favours LSM, read-heavy transactional work favours B-trees.
Index types and index design
Picking the right structure
PostgreSQL offers several index types. B-tree is the default and supports equality, range and sorted retrieval. Hash supports equality only. GIN suits values containing many elements, such as arrays, JSON documents and full-text search. GiST supports geometric and nearest-neighbour queries. BRIN stores summaries per block range and is tiny for very large tables whose physical order tracks the column, such as timestamps.
Shaping indexes to queries
In a multicolumn B-tree, column order matters: the index is most effective when leading columns have equality conditions. Partial indexes cover only rows matching a predicate, and expression indexes cover computed values. An index that contains every column a query needs enables an index-only scan that avoids the table. Every index slows writes and consumes memory and disk, so add them for measured query patterns and remove unused ones.
MVCC and its costs
Snapshots instead of read locks
Under multiversion concurrency control, each statement or transaction sees a snapshot of the data as of some point in time, so reading never blocks writing and writing never blocks reading. In PostgreSQL an update creates a new row version and leaves the old one for transactions that can still see it. The default isolation level is Read Committed, where each statement sees data committed before it began; Repeatable Read and Serializable hold one snapshot for the transaction and may fail with serialization errors that the application must retry. The cost is garbage: dead row versions accumulate until vacuum reclaims them, and a long-running transaction prevents cleanup, causing table and index bloat. Keep transactions short and monitor the oldest open one.
Reading query plans
EXPLAIN as the source of truth
EXPLAIN shows the plan tree the planner chose: scan nodes at the leaves, such as sequential, index, bitmap or index-only scans, and join or sort nodes above them. Each node carries an estimated startup cost, total cost, row count and row width. EXPLAIN ANALYZE executes the query and adds actual times, row counts and loop counts; the BUFFERS option adds pages hit and read. Read the plan from the innermost node outward and compare estimated to actual rows. A large mismatch points to stale statistics or correlated columns and usually explains a bad join choice. A sequential scan is not automatically wrong: for a query returning a large share of a table it is cheaper than an index. ANALYZE really runs the statement, so wrap data-modifying queries in a transaction and roll back.
Sources
- docPostgreSQL indexes (www.postgresql.org)
- docPostgreSQL MVCC and concurrency control (www.postgresql.org)
- docPostgreSQL using EXPLAIN (www.postgresql.org)
- docRocksDB wiki (LSM-tree internals) (github.com)
- articleBuilding and operating a pretty big storage system (S3) (www.allthingsdistributed.com)