Back to SWE/DE overview
Level 2 · Application

Every engine picks a corner of the same triangle

SQL engines and Spark run the same small set of mechanisms: a storage engine that trades read, write, and space costs, index lookups that trade random for sequential I/O, and join algorithms that trade memory for passes over the data. This level argues each one from first principles, then maps it to SQL and Spark, from basics to high performance.

Each topic follows the North Star order: what the decision is, why first principles favor one answer, and how to apply it.

The RUM conjecture: read, update, memory — pick two

No access method minimizes read, write, and space overhead at once; every storage engine is a chosen point on that triangle.

What are you trying to do?

Pick a storage engine by the workload's read/write mix, not by the product's popularity.

Why are you doing it?

Making reads cheap means maintaining structure at write time (sorted pages, indexes), which costs writes. Making writes cheap means deferring that structure (append now, organize later), which costs reads or space until compaction catches up. Making space cheap means giving up redundant structures that speed up one or the other. The RUM conjecture (Athanassoulis et al., 2016) names that triangle; storage engine families are its corners.

Swipe sideways if the diagram is cut off.

Read overhead
Corner = cheap reads: few I/Os per lookup or scan
Update overhead
Corner = cheap writes: little extra data written per logical write
Memory overhead
Corner = cheap space: few extra bytes stored per live byte, including indexes

Marker positions are qualitative, drawn from the amplification column of the storage-engine table, not measured. Moving toward one corner moves away from the others.

Database InternalsDesigning Data-Intensive Applications (2nd ed.)

How are you going to do it?

  • Measure the workload first: write rate, read pattern (point vs range vs full scan), and how much data is hot.
  • Express engine costs as amplification: read amplification (I/Os per logical read), write amplification (bytes written per logical byte), space amplification (bytes stored per live byte).
  • Choose the engine whose cheap corner matches the dominant operation, then tune the knobs that move along the triangle (fill factor, compaction strategy, bloom filter size).

Storage engines: B-tree vs LSM-tree, and the rest

B-trees update in place and favor reads; LSM-trees append and compact and favor writes. Other engines fill specific corners.

What are you trying to do?

Know how each engine family turns a logical read or write into physical I/O.

Why are you doing it?

A B+tree keeps keys sorted in fixed-size pages (often 4–16 KB) with a high fan-out, so a lookup touches about log_fanout(N) pages — three or four for billions of rows — and range scans walk linked leaves. The cost is on writes: each update rewrites a whole page in place (plus a write-ahead-log record), and random inserts split pages. An LSM-tree buffers writes in a sorted in-memory memtable (backed by a WAL), flushes it as an immutable sorted SSTable, and merges SSTables in the background. Writes become sequential and cheap; reads may check several levels, so engines add bloom filters and fence pointers to skip most of them, and compaction spends I/O later to reclaim space.

B+tree write path — update in place

  1. Find leaf page

    Descend about log_fanout(N) pages

  2. Log to WAL

    Record the change for crash recovery

  3. Modify page in place

    Rewrite the whole fixed-size page

  4. Split if full

    Random inserts split pages, leaving some half full

LSM-tree write path — append and compact

  1. Append to WAL

    Sequential log for crash recovery

  2. Insert into memtable

    Sorted in-memory buffer

  3. Flush SSTable

    Immutable sorted file, written sequentially

  4. Compact in background

    Merge SSTables; reclaim space, spend I/O later

LSM-tree read path — skip what you can

  1. Check memtable

    Newest data first

  2. Bloom filter

    Skip SSTables that cannot hold the key

  3. Fence pointers

    Jump to the one block that may hold it

  4. Read newest-first

    May still touch several levels

The B+tree pays at write time to keep data organized for reads; the LSM-tree defers that work to flush and compaction.

Database InternalsDesigning Data-Intensive Applications (2nd ed.)

How are you going to do it?

  • Read-heavy OLTP with range queries and strict latency: a B+tree engine (PostgreSQL, InnoDB).
  • Write-heavy ingestion, time series, or key-value at scale: an LSM engine (RocksDB, Cassandra, ScyllaDB), with compaction chosen per workload — leveled for read-heavy, size-tiered for write-heavy.
  • Analytics: columnar files with zone maps — a different corner entirely, optimized for scans rather than lookups.
Storage engine families and their trade-offs
EngineWrite pathRead pathAmplification profileChoose whenExamples
B+treeFind leaf page, modify in place, log to WAL; page splits on growthO(log N) page reads; efficient range scans across linked leavesLow read amp; higher write amp (whole-page writes); some space lost to partially full pagesRead-heavy, point and range queries, transactional updatesPostgreSQL, MySQL InnoDB, SQLite
LSM-treeAppend to WAL and memtable; flush sorted SSTables; compact in backgroundCheck memtable then levels newest-first; bloom filters skip most SSTablesLow write amp at ingest but compaction rewrites data; higher read and space amp until compactionWrite-heavy ingestion, time series, large key-value storesRocksDB, LevelDB, Cassandra, ScyllaDB, HBase
Log-structured hash indexAppend to log; update in-memory hash map of key → offsetOne hash lookup plus one seekMinimal read and write amp; all keys must fit in memory; no range queriesHigh-rate point lookups on a bounded key setBitcask (Riak)
Columnar + zone mapsBatch-written immutable files or row groups; updates via rewrite or delete filesScan only needed columns; skip chunks whose min/max excludes the predicateExcellent compression; very high cost per single-row updateAnalytical scans and aggregatesParquet, ORC, Delta Lake, Iceberg, ClickHouse
Inverted indexTokenize and append to posting lists in immutable segments; merge segmentsLook up term → posting list; intersect listsLarge index relative to data; fast term and full-text lookupsSearch, log exploration, full-text filteringLucene, Elasticsearch, OpenSearch
Bε-treeBuffer writes in internal nodes; flush down in batchesNear B-tree point reads; must check buffers on the pathWrite cost between B-tree and LSM; read cost close to B-treeMixed workloads that need both cheap writes and fast readsTokuDB / PerconaFT, BetrFS
Skip list (in-memory)Probabilistic multi-level linked list; lock-free inserts possibleExpected O(log N) search and ordered iterationPointer-heavy, so cache-unfriendly; memory onlySorted in-memory buffersLSM memtables (RocksDB, LevelDB), Redis sorted sets

Index lookups: seek vs scan

An index helps only when it avoids enough I/O to beat a sequential scan; selectivity decides.

What are you trying to do?

Make the optimizer's access-path choice predictable.

Why are you doing it?

An index seek is a handful of random page reads per matching row; a full scan is sequential I/O over every page. Random reads cost far more per byte than sequential ones, so past a selectivity threshold (often a few percent of rows) a scan wins. Clustered indexes store rows in key order so range reads stay sequential; secondary indexes add a lookup back to the row unless they cover every column the query needs.

Full scan

Every page, sequential I/O

  • P1 (read)
  • P2 (read)
  • P3 (read)
  • P4 (read)
  • P5 (read)
  • P6 (read)
  • P7 (read)
  • P8 (read)
  • P9 (read)
  • P10 (read)
  • P11 (read)
  • P12 (read)

Index seek, selective predicate

Root, leaf, then one table page: random reads, far fewer pages

  • Root (read)
  • Leaf (read)
  • P1
  • P2
  • P3
  • P4
  • P5 (read)
  • P6
  • P7
  • P8
  • P9
  • P10
  • P11
  • P12

Index seek, low selectivity

Matching rows spread over most pages: many random reads, scan wins

  • Root (read)
  • Leaf (read)
  • P1 (read)
  • P2 (read)
  • P3
  • P4 (read)
  • P5 (read)
  • P6 (read)
  • P7
  • P8 (read)
  • P9 (read)
  • P10 (read)
  • P11
  • P12 (read)

Highlighted cells are pages read to answer the query.

Page counts are illustrative. A seek wins when it skips most pages; as selectivity drops, random reads add up and the sequential scan wins.

PostgreSQL Query OptimizationHigh Performance MySQL (4th ed.)

How are you going to do it?

  • Read the plan (EXPLAIN ANALYZE) before adding an index; confirm estimated vs actual row counts, since bad statistics cause most bad plans.
  • Order composite index columns by equality predicates first, then range predicates; add included columns to make hot queries covering.
  • In InnoDB the primary key is the clustered index, so a random UUID primary key scatters inserts across pages; prefer monotonic keys.
Index types and what a lookup costs
IndexStructureLookup costUse when
Clustered (primary)Rows stored in key order inside the B+tree leavesOne traversal; ranges read sequentiallyPrimary access path, range queries on the key
SecondarySeparate B+tree of key → row pointer or primary keyTraversal plus one lookup per matching rowSelective predicates on non-key columns
CoveringSecondary index that includes every column the query readsTraversal only; no row lookup (index-only scan)Hot, read-heavy queries with a known column set
No index (full scan)Read every page sequentiallyLinear in table size but sequentialLow-selectivity predicates, small tables, analytics

Join algorithms, from one node to a cluster

Nested-loop, hash, and sort-merge joins trade memory for I/O; Spark adds broadcast vs shuffle on top of the same three.

What are you trying to do?

Predict which join an engine will pick, and why it might pick wrong.

Why are you doing it?

A nested-loop join is cheap when the outer side is small and the inner side has an index. A hash join builds a hash table on the smaller side and streams the larger one past it — linear time, but the build side must fit in memory or it spills. A sort-merge join sorts both sides on the key and merges them — it costs a sort but needs little memory and handles any size. Distribution adds one more choice: either copy the small side to every node (broadcast), or repartition both sides by key so matching rows meet (shuffle). The shuffle is a full network exchange and is usually the most expensive step in a Spark job.

Nested loop, no index — outer rows × inner scan

  1. Take outer row

    Ideally a tiny side

  2. Scan inner side

    Full pass per outer row

  3. Emit matches

    Low memory, quadratic work

Nested loop, indexed inner — outer rows × seek

  1. Take outer row

    Small outer side

  2. Seek inner index

    One lookup per outer row

  3. Emit matches

    Low memory

Hash join — build, then probe

  1. Build hash table

    On the smaller side; must fit in memory or spill

  2. Probe

    Stream the larger side past the table

  3. Emit matches

    Linear: build + probe

Sort-merge join — sort both, merge once

  1. Sort side A

    External sort if too big for memory

  2. Sort side B

    Skipped if already sorted on the key

  3. Merge

    One linear pass over both

Distributed: broadcast — copy the small side

  1. Broadcast small side

    Copy to every node

  2. Join locally

    Large side stays in place, no shuffle

Distributed: shuffle — meet by key

  1. Repartition both sides

    Hash by join key over the network

  2. Join each partition

    Sort-merge by default for large-large

Joins trade memory against passes over the data: hash needs memory, sort-merge pays for a sort, and distribution adds broadcast or shuffle.

PostgreSQL Query OptimizationSpark: The Definitive GuideHigh Performance Spark (2nd ed.)

How are you going to do it?

  • Keep statistics fresh so the optimizer knows which side is small.
  • In Spark, small dimension tables should be broadcast (spark.sql.autoBroadcastJoinThreshold or a broadcast hint); large-to-large joins fall back to shuffle sort-merge.
  • Turn on adaptive query execution (AQE, the default since Spark 3.2). It re-plans at runtime from real shuffle sizes: it switches to broadcast, coalesces small partitions, and splits skewed ones.
Join algorithms and their distributed forms
JoinCost shapeMemory needBest whenSpark form
Nested loopOuter rows × inner lookupLowTiny outer side with an indexed inner sideBroadcast nested loop (non-equi joins)
Hash joinLinear: build + probeBuild side in memoryOne side fits in memoryBroadcast hash join; shuffle hash join
Sort-merge joinSort both sides, then linear mergeLow (external sort)Both sides large, or already sortedShuffle sort-merge join (default for large-large)

Spark execution: the same principles, distributed

Spark SQL is a relational optimizer over partitions; most tuning means reading less data and shuffling less of it.

What are you trying to do?

Turn a slow Spark job into a plan you can explain stage by stage.

Why are you doing it?

Catalyst rewrites a logical plan (pushing predicates down, pruning columns), picks a physical plan (join strategies), and Tungsten compiles stages into generated code over off-heap binary rows. A job splits into stages at every shuffle boundary, and each stage runs one task per partition. Elapsed time is therefore usually set by bytes read, bytes shuffled, and the slowest (most skewed) task — not by CPU work inside a task.

Planning — on the driver

  1. Logical plan

    What to compute, built from the query

  2. Optimize

    Push down predicates, prune columns

  3. Physical plan

    Pick join strategies; compile stages to code

Execution — on the cluster

  1. Split into stages

    A new stage at every shuffle boundary

  2. One task per partition

    Run on executors; the slowest task sets stage time

  3. Adaptive re-plan

    AQE uses real shuffle sizes: broadcast, coalesce, split skew

Elapsed time is usually set by bytes read, bytes shuffled, and the slowest task, so tuning targets those rather than CPU inside a task.

Learning Spark (2nd ed.)Spark: The Definitive GuideHigh Performance Spark (2nd ed.)

How are you going to do it?

  • Read less: partition and cluster data on common filter columns so partition pruning and predicate pushdown skip files and row groups.
  • Shuffle less: broadcast small sides, aggregate before joining, and avoid wide transformations you do not need.
  • Fix skew: AQE skew-join splitting, or salting hot keys by hand; check the stage's task-time distribution in the Spark UI.
  • Right-size partitions (roughly 100–200 MB each) so tasks neither spill nor drown the scheduler in overhead.
Reading path

What to read for the application level

  1. 2

    Storage engines

    Application

    Designing Data-Intensive Applications (2nd ed.)Database Internals

    You can now explain: B-tree vs LSM-tree, and the read, write, and space amplification each one trades.

  2. 3

    SQL, basics to high performance

    Application

    Learning SQL (3rd ed.)PostgreSQL Query OptimizationHigh Performance MySQL (4th ed.)The Data Warehouse Toolkit (3rd ed.)SQL Antipatterns, Volume 1

    You can now explain: Execution plans, index choice, join algorithms, and dimensional modeling.

  3. 4

    Spark, basics to high performance

    Application

    Learning Spark (2nd ed.)Spark: The Definitive GuideHigh Performance Spark (2nd ed.)

    You can now explain: Shuffles, join strategy selection, adaptive query execution, and Tungsten.