Part 4 · Databases & Data at Scale

Database Indexing: B-Trees and LSM-Trees

Why an index turns 812 ms into 0.09 ms, and why it slows every write. How B-trees keep sorted pages updated in place, how LSM-trees append and compact for huge write rates, and how to design indexes from real queries.

Lesson 2212 min readintermediate#database#indexing

On this page

  1. The Problem
  2. The Core Idea
  3. How It Works
  4. Trade-offs
  5. In the Real World
  6. Interview Questions
  7. Key Takeaways
  8. Further Reading

The Problem

In Lesson 5 we saw a query go from 812 ms to 0.09 ms after we added an index. An index is an extra structure that the database keeps so that it can find rows fast. The improvement was big. But some questions remain:

  • Why does an index make reads fast?
  • Why do indexes make writes slower?
  • Why can some databases (like Cassandra) handle millions of writes per second, while others (like PostgreSQL) are very good at complex reads?

The answer is in the storage engine. The storage engine is the part of the database that decides how data is laid out and stored on disk. The two big families are B-trees and LSM-trees. A tree here means data arranged in levels, like a family tree. If you understand them, you can pick the right database and design the right indexes.

The Core Idea

Imagine two ways to run a big library.

Library 1: the carefully sorted shelves (B-tree).

  • Every book has an exact place on a sorted shelf.
  • Signs at the entrance say "A–F: floor 1", then "A–C: aisle 3", then "shelf 2".
  • Finding a book takes only a few quick steps.
  • But when a new book arrives, a librarian must walk to its exact spot and squeeze it in. If the shelf is full, some books must move to a new shelf.

Library 2: the inbox and the nightly sort (LSM-tree).

  • New books are dropped into a sorted tray at the front desk. This is very fast.
  • When the tray is full, it is put in a box and stored as a sorted box. A box is never changed again.
  • At night, staff merge several boxes into bigger sorted boxes. They also throw away outdated copies.
  • To find a book, you may need to check the tray and then a few boxes. But there are tricks to skip boxes that surely do not have the book.

B-trees are best for reading and for updating data in place. LSM-trees are best for writing fast.

How It Works

Why we need indexes at all

Without an index, finding WHERE email = 'asha@example.com' means reading every row. This is a full scan, and the work grows with the number of rows n: O(n). With a sorted structure you can use binary search. Binary search checks the middle, then keeps only the half that can hold the answer, and repeats. The work grows very slowly: O(log n).

RowsFull scan (worst case)Sorted lookup (~log₂ n)
1,0001,000 checks~10
1,000,0001,000,000~20
1,000,000,0001,000,000,000~30

That is why indexes make such a big difference as tables grow.

B-trees (and B+trees)

A B-tree is a sorted tree that is wide and shallow. It is stored in fixed-size blocks called pages on disk (8 KB in PostgreSQL and 16 KB in MySQL InnoDB by default). Each page is one node of the tree.

Text
                     [  50  |  100  ]                  ← root page                    /        |        \         [10 | 25 | 40]   [60 | 75 | 90]   [120 | 150]   ← internal pages          /  |  |  \        ...     [1..9][10..24][25..39][40..49]  ...                 ← leaf pages (point to rows)
  • Each page holds hundreds of keys, so every node has many children. This is called high fan-out.
  • Because of that, even a billion rows need only about 3–4 levels. That means 3–4 page reads to find anything. The top levels are usually kept in memory, so often it is just 1–2 disk reads.
  • In a B+tree (the version most databases really use), the bottom pages (leaves) are linked in order. So range queries (WHERE created_at BETWEEN ...) are fast. You find the start, then walk along the leaves.

Writes in a B-tree:

  1. Find the right leaf page.
  2. Insert or update the key in place.
  3. If the page is full, split it into two and update the parent.

Every write may touch several pages, in random places on disk. For crash safety, the change is written to the WAL (write-ahead log, a file of changes) first. See Lesson 21.

B-trees are good for:

  • fast point lookups (find one key) and range scans (find all keys between two values),
  • predictable read speed,
  • the default in PostgreSQL, MySQL InnoDB, SQL Server, Oracle and SQLite.

LSM-trees (Log-Structured Merge-trees)

An LSM-tree (Log-Structured Merge-tree) never updates data in place. It only adds new data and merges it later. These are the steps:

  1. 1
    Commit log
    added to the end of a file for crash safety (sequential)
  2. 2
    Memtable
    a sorted table in memory; the write is done here
  3. 3
    SSTables on disk
    immutable (never changed) sorted files, newest → oldest, each with a Bloom filter
  4. 4
    Compaction
    a merge in the background; keep the newest version of each key, drop tombstones (delete markers)
The LSM-tree write path. Nothing on disk is ever changed. Data is only added, and merged later. · flow

Why writes are fast: a write is just an append to a log plus an insert in memory. Data goes to disk in big sequential chunks (one after another). This is good for both SSDs and spinning disks.

Reads are more work:

  1. Check the memtable.
  2. Check the newest SSTable, then older ones, until you find the key. An SSTable (sorted string table) is one sorted file on disk.

To keep this fast:

  • Bloom filters. A Bloom filter is a small, compact structure that tells you "this key is definitely not in this file" or "it might be". It never gives a wrong "not here" answer. Each SSTable has one. So most files are skipped without reading them from disk.
  • Sparse indexes inside each SSTable (an index with only some of the keys) let the engine jump close to the right spot.
  • Compaction (merging files in the background) keeps the number of files small.

Deletes are written as tombstones. A tombstone is a marker that says "key X was deleted". The data only really disappears when compaction runs.

Compaction strategies:

  • Size-tiered: merge files of similar size. Good for write-heavy loads. But it uses more temporary disk space.
  • Levelled: organise files into levels. Inside one level, the files do not share any keys. Reads are better and less space is used. But data is rewritten more often.

LSM-trees are used by: RocksDB and LevelDB (embedded engines), Cassandra, ScyllaDB, HBase, Google Bigtable, and the storage layers of many modern databases (CockroachDB and TiDB use LSM-based engines).

Hash indexes

The simplest index is a hash map. It maps each key to a position on disk. It is very fast for exact lookups (WHERE id = 42). But it is useless for ranges (WHERE id > 42) and for sorting. Some key-value stores use this design (for example, the Bitcask model). PostgreSQL also offers hash indexes, but B-trees are almost always the better choice.

B-tree
  • +Point lookups and range scans in 3–4 page reads
  • +Predictable reads; mature transactions
  • −Random in-place writes; page splits
  • −Suffers under random keys (UUIDv4)
Use whenPostgreSQL, MySQL InnoDB, SQL Server, Oracle, SQLite
LSM-tree
  • +Very high write throughput; sequential I/O
  • +Compresses well
  • −Reads may check several files (Bloom filters help)
  • −Compaction costs CPU/disk; can cause latency spikes
Use whenCassandra, ScyllaDB, RocksDB, HBase, Bigtable
The two storage-engine families. · compare

The three "amplifications"

Engineers compare storage engines using three costs. "Amplification" means doing more work than the minimum:

MeaningB-treeLSM-tree
Write amplificationBytes actually written to disk per byte you writeMedium (pages rewritten, plus WAL)Can be high (data is rewritten during compaction), but the writes are sequential
Read amplificationPlaces you must check per readLow (walk one tree)Higher (memtable + several files), reduced by Bloom filters
Space amplificationExtra disk used beyond the real dataSome (half-empty pages after splits)Old versions until compaction; lower with levelled compaction

Roughly: B-trees are best for reads. LSM-trees are best for writes. Real performance depends a lot on the workload and the settings, so test with your own data.

Types of indexes you will use

Clustered vs secondary (non-clustered).

  • A clustered index stores the table rows themselves in index order. In MySQL InnoDB, the primary key is the clustered index. Lookups by primary key are very fast. A table can have only one clustered index.
  • A secondary index is any other index. It stores the indexed column(s) plus a pointer to the row. In InnoDB the pointer is the primary key. In PostgreSQL it is the row location. Using it takes one lookup in the index plus one fetch of the row.

Composite (multi-column). One index over several columns, for example (country, city, created_at). It follows the left-prefix rule from Lesson 5: it can help a query on country, or on country and city, but not on city alone.

Covering index. It contains all the columns a query needs. So the database never has to fetch the table row. The INCLUDE columns are stored in the index but are not part of the sort key:

SQL
CREATE INDEX idx_orders_user_cover ON orders (user_id, created_at) INCLUDE (total, status);-- SELECT created_at, total, status FROM orders WHERE user_id = 42 → index-only scan

Unique index. It does not allow two rows with the same value. It is the safety net from Lesson 21.

Partial index. It only indexes the rows that match a condition. This saves space:

SQL
CREATE INDEX idx_pending_orders ON orders (created_at) WHERE status = 'pending';

Specialised indexes (PostgreSQL examples):

  • GIN (an inverted index, like the one in Lesson 19) for arrays, JSONB and full-text search,
  • GiST / SP-GiST (flexible tree indexes) for shapes and map data,
  • BRIN (a very small index that stores the min and max value of each block of rows) for huge tables whose rows are already in order, like time-series logs,
  • geospatial indexes (PostGIS, geohashes) for "restaurants near me". PostGIS is a PostgreSQL extension for map data. A geohash is a short text code for a map area.

Choosing indexes in practice

  1. Start from real queries. Look at the slow-query log (a list of slow queries) and at the most frequent queries.
  2. Index columns used in WHERE, JOIN and ORDER BY. In a composite index, put the columns that you compare with equality first, and a range column last.
  3. Check with EXPLAIN ANALYZE (Lesson 5). It shows the plan the database really used and how long each step took.
  4. Do not add too many indexes. Each index slows every insert and update and uses memory. A write-heavy table with 15 indexes will struggle.
  5. Remove unused indexes. Most databases can tell you which indexes are never used.
  6. Watch for low-selectivity columns. Selectivity means how well a value narrows down the rows. An index on is_active (true/false) has low selectivity. It rarely helps on its own.
  7. Remember that indexes work best in memory. If your most-used indexes do not fit in RAM, performance drops sharply.

Trade-offs

B-tree engines

  • ✅ Fast, predictable reads and range scans. Mature. Strong transaction support.
  • ❌ Random writes, and page splits under heavy insert load, especially with random keys like UUIDv4.

LSM engines

  • ✅ Very high write throughput (many writes per second), sequential I/O (disk access in order) and good compression.
  • ❌ Reads may check many files. Compaction uses background CPU and disk. It can cause latency spikes (sudden slow responses) if it falls behind. Tuning is harder.

Indexes in general

  • ✅ Huge read speedups.
  • ❌ Slower writes, more storage and memory, and more choices for the query planner (the part of the database that picks how to run a query).

Rule of thumb: apps with many reads, relational data and transactions usually suit B-tree databases. Massive streams of writes (events, logs, messages, metrics) suit LSM-based databases.

In the Real World

Facebook's MyRocks. Facebook built MyRocks. It replaces MySQL's InnoDB (B-tree) storage engine with RocksDB (LSM). For its main user database, this cut storage space and write amplification by a large amount. Engineers could keep using the same MySQL interface.

Cassandra and ScyllaDB at Discord, Netflix and Apple. These LSM-based databases are chosen for huge write volumes such as messages, viewing history and events. In these cases fast appends matter more than complex queries.

RocksDB everywhere. RocksDB was created at Facebook as a fork of Google's LevelDB. Many other systems embed it (run it inside themselves): databases like CockroachDB (in its earlier versions) and TiKV, and stream processors like Kafka Streams and Apache Flink (to keep local state). When you use these systems, you often use an LSM-tree underneath.

UUID primary keys and B-trees. Many teams have seen insert speed drop after they switched to random UUIDv4 primary keys in B-tree databases. Random keys scatter the inserts across the whole index and cause many page splits. Time-ordered IDs (UUIDv7, Snowflake-style IDs) keep new keys close together, which fixes this.

Geospatial search. Apps like food delivery or ride-hailing need "find drivers or restaurants near this location" queries. They use specialised spatial indexes (like PostGIS GiST indexes, geohashes or grid systems like Uber's H3) because a normal B-tree on latitude and longitude cannot answer "nearby" efficiently.

Interview Questions

Q1Why does a B-tree need only 3–4 reads for a billion rows?

Each page holds hundreds of keys, so the fan-out (children per node) is huge. Roughly 500 × 500 × 500 ≈ 125 million leaf pages at depth three. The top levels stay in memory, so a lookup is often only one or two disk reads.

Q2Why are LSM-trees faster for writes?

A write is an append to a sequential log plus an insert into an in-memory table. Data reaches disk in large sequential flushes, never as random in-place updates. The cost moves to reads (which may check several files) and to background compaction.

Q3What does a Bloom filter do in an LSM engine?

It is a small structure, one per SSTable, that answers “this key is definitely not here” or “it might be here”. It is probabilistic: it can say “might be” by mistake, but never “not here” by mistake. Reads skip most files without touching the disk. This keeps read amplification low.

Q4What is a covering index?

It is an index that contains every column a query needs. The database answers from the index alone (an index-only scan) and does not fetch table rows. Example: (user_id, created_at) INCLUDE (total, status).

Q5Why can random UUID primary keys hurt a B-tree database?

Random keys land all over the index. So inserts touch random pages, cause frequent page splits and need much more of the index to stay in memory. Time-ordered IDs (UUIDv7, Snowflake) keep inserts near the right edge of the tree.

Q6When would you not add an index?

Do not add one on a write-heavy table when the query is rare. Do not add one on a low-selectivity column (like a boolean) by itself. Do not add one when an existing composite index already covers the query through its left prefix. Every index costs write time, disk space and memory.

Key Takeaways

  • Indexes turn O(n) scans into O(log n) lookups. This makes a huge difference as tables grow.
  • B-trees keep data in sorted pages, updated in place: excellent for reads and ranges, and the default in most relational databases.
  • LSM-trees append to memory and immutable files, then compact in the background. This is excellent for writes. Bloom filters keep reads fast enough.
  • Engines trade write, read and space amplification against each other. Test with your own workload.
  • Design indexes from real queries (composite, covering, partial, specialised). Avoid over-indexing, and prefer time-ordered keys in B-trees.

Further Reading

  • Designing Data-Intensive Applications by Martin Kleppmann (chapter 3, "Storage and Retrieval")
  • Database Internals by Alex Petrov (Part I, on storage engines)
  • The paper "The Log-Structured Merge-Tree (LSM-Tree)" by O'Neil et al. (1996)
  • The RocksDB wiki on GitHub (compaction, Bloom filters, tuning)
  • Use The Index, Luke! by Markus Winand
  • CMU 15-445 Database Systems lectures on B+tree indexes

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series