System Design Primer · llms.txt
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
What happens on disk when you run INSERT INTO users VALUES (...), or SELECT * FROM orders WHERE user_id = 42?
The primer's database chapter covers relational and NoSQL databases, replication and sharding at a high level. In a design discussion, "we'll use a database" and "we'll shard by user ID" are only the start. The follow-up questions are about what's underneath: why a B-Tree engine like InnoDB struggles with a heavy random-write load that Cassandra or RocksDB absorbs, how many disk reads a lookup costs, why the order of columns in a composite index matters, and how Google Spanner uses clocks to order transactions across the world.
This chapter works up from the disk: the two main storage engine designs (B-Trees and log-structured merge-trees), how indexes are laid out, sharding strategies and the mistakes that hurt most, and distributed SQL databases such as Spanner and CockroachDB. It builds on CAP and consistency 101.
Storage engines are shaped by one fact: sequential I/O is much cheaper than random I/O.
On a spinning disk that's obvious, because a random read means moving the head. SSDs have no moving parts, but the gap is still there. A flash drive can't overwrite a page in place; it writes new pages and erases old blocks in the background, so lots of small random writes cause extra internal copying (write amplification inside the drive) and wear. Large sequential writes are the cheapest thing you can ask a drive to do.
Two families of engines respond to this differently.
Rudolf Bayer and Edward McCreight described the B-Tree in 1970 (the paper appeared in 1972). Its variant, the B+ Tree, is what MySQL's InnoDB, PostgreSQL's indexes, SQLite and SQL Server use.
WHERE age BETWEEN 20 AND 30 walks down the tree once to the first matching leaf, then follows the links sideways.To change one 100-byte row, InnoDB can't write just those 100 bytes. It:
If a page is full, an insert splits it into two, and the parent page has to change too.
To survive a crash between those steps, B-Tree engines keep a write-ahead log (WAL): before a change is applied to a data page, a record of it is appended to the log and flushed. In MySQL it's called the redo log; PostgreSQL calls it the WAL. After a crash, the engine replays the log to redo changes that hadn't reached the data pages. Because a 16 KB page write can be torn halfway by a power cut, engines also protect the pages themselves: InnoDB writes each page to a doublewrite buffer first, and PostgreSQL logs a full copy of each page the first time it changes after a checkpoint.
So every change is written at least twice, once to the log and once to the page, and a small change costs a whole page write. That's write amplification. With many writes spread across a large table, page flushes become random I/O, and that's where B-Tree engines run out of disk throughput first.
In 1996, Patrick O'Neil, Edward Cheng, Dieter Gawlick and Elizabeth O'Neil published The Log-Structured Merge-Tree (LSM-Tree). The idea: never update data in place on disk. Buffer writes in memory, write them out as new sorted files, and merge files in the background.
LSM-trees are used by LevelDB, RocksDB, Cassandra, ScyllaDB, HBase, Google Bigtable, and CockroachDB (through Pebble, its RocksDB-like engine).
PUT key value or DELETE key) is appended to a log on disk so it survives a crash. Appends are sequential.A key's newest value may be in the MemTable or in any SSTable, so a read checks newest first:
Checking many files for one key is read amplification. Two things keep it down:
Two common compaction strategies:
| B-Tree (InnoDB, PostgreSQL) | LSM-tree (RocksDB, Cassandra) | |
|---|---|---|
| Good at | Predictable reads and range scans | High write rates |
| How a write lands | Log append now, page overwrite later | Log append and MemTable insert; files written sequentially later |
| Write amplification | Whole page per small change, plus the log | Data rewritten by each compaction, but always sequentially |
| Read cost | One path down the tree, often one or two disk reads | MemTable plus several files; Bloom filters skip most |
| Space overhead | Partly empty pages after splits and deletes | Old versions until compacted; low with leveled compaction, higher with size-tiered |
| Latency spikes | Checkpoints can cause flush bursts | Heavy compaction can compete with foreground reads and writes |
Neither wins everywhere. Benchmark with your own workload: plenty of write-heavy systems run happily on PostgreSQL, and RocksDB serves read-heavy workloads too.
An index is an extra data structure that lets the database find rows without reading the whole table. Missing or badly ordered indexes are one of the most common reasons a query is slow.
Clustered index. The table's rows are stored inside the index itself, in key order.
CLUSTER command reorders the heap once but doesn't keep it ordered.)Secondary index. An index on other columns, such as CREATE INDEX idx_email ON users(email).
SELECT * FROM users WHERE email = 'alice@example.com' is two searches: down the email index to find user_id = 42, then down the clustered index to fetch the row.Covering index. If every column a query needs is in the index, the database answers from the index alone and skips the second lookup. This is an index-only scan.
SELECT user_id, email FROM users WHERE email = 'alice@example.com' is covered by idx_email in InnoDB, because the leaf holds email and the primary key user_id.(email, full_name), or in PostgreSQL and SQL Server, INCLUDE (full_name), which stores the column in the leaves without making it part of the key.The trade-off: every index makes writes slower and takes space, and wide covering indexes do so more.
A composite index covers several columns:
CREATE INDEX idx_orders ON orders (user_id, status, created_at);
Entries are sorted by user_id, then by status within the same user_id, then by created_at within the same status, like a phone book sorted by last name and then first name. The index helps when a query constrains a leftmost prefix of those columns:
-- Uses all three columns
SELECT * FROM orders WHERE user_id = 10 AND status = 'PAID' AND created_at > '2026-01-01';
-- Uses user_id and status
SELECT * FROM orders WHERE user_id = 10 AND status = 'PAID';
-- Uses user_id only
SELECT * FROM orders WHERE user_id = 10;
-- Can't seek with this index: the first column isn't constrained
SELECT * FROM orders WHERE status = 'PAID';
SELECT * FROM orders WHERE created_at > '2026-01-01';
The last two are like looking up everyone named "Maria" in a phone book sorted by last name: the order doesn't help. (Some engines can still use the index there, with tricks such as MySQL's skip scan when the first column has few distinct values, but don't design around that.)
Equality first, range last. Once the index hits a range condition (<, >, BETWEEN, LIKE 'abc%'), the columns after it can't narrow the search any further. Take the query WHERE user_id = 10 AND status = 'PAID' AND created_at > '2026-01-01':
(user_id, created_at, status), the database seeks to user_id = 10, scans every entry with a recent created_at, and checks status on each one as it goes.(user_id, status, created_at), it seeks straight to user_id = 10, status = 'PAID' and scans only the matching date range.So put columns compared with = first and the range column last.
When a table outgrows the storage or write capacity of the biggest single server you're willing to run, you split its rows across several databases. Each database holds a shard, and a shard key decides which shard a row belongs to.
A query that includes the shard key goes to one shard. A query without it has to ask all of them.
Each shard holds a continuous range of the key: user IDs 1–1,000,000 on shard 1, 1,000,001–2,000,000 on shard 2, or one shard per month.
WHERE id BETWEEN 500 AND 600) touch one shard or a few neighbors.Hash the shard key and use the hash to pick a shard.
WHERE user_id BETWEEN 10 AND 100) has to go to every shard.The simplest version is shard = hash(user_id) mod N. Its problem is changing N: going from 4 to 5 shards changes the shard for about 80% of keys, so most of the data has to move. Consistent hashing fixes that by placing shards and keys on a ring. Adding a shard takes over only the keys next to it, about 1/N of the total, and virtual nodes keep the load even. Many systems take a simpler route with the same effect: split the hash space into many more fixed partitions than there are servers (say 1,024), and move whole partitions between servers.
A lookup service maps each key, or each tenant, to a shard: tenant A → shard 3, tenant B → shard 1. The mapping lives in a small, strongly consistent store such as etcd, ZooKeeper or a replicated SQL table, and routers cache it.
The range case above is the classic example, but hash partitioning has its own version: a key that's hot by nature. Sharding by tenant_id when one tenant generates half the traffic, or by celebrity_id for a social feed, puts that load on one shard no matter how the hashing works. Look at the distribution of traffic per key, not just the number of distinct keys, before choosing.
If a query doesn't include the shard key, for example SELECT * FROM orders WHERE status = 'PROCESSING' ORDER BY created_at DESC LIMIT 10 on a table sharded by user_id, the router can't tell which shard has the answer. It has to:
The query is as slow as the slowest shard. If each shard has a 1% chance of a slow response, a query that touches 50 shards hits at least one slow shard 1 − 0.99⁵⁰ ≈ 40% of the time. Every scatter query also uses capacity on every shard, so total query load grows with the number of shards. Occasional scatter queries are fine. If a frequent query needs a different key, keep a second copy of the data sharded or indexed by that key, for example a global secondary index or a separate table maintained from a change stream.
On one server, joining users to orders is a local operation. Shard both tables on unrelated keys, and the join has to ship rows over the network to some coordinator to be matched. A transaction that updates rows on two shards needs a distributed transaction protocol such as two-phase commit, which is slower and harder to operate.
The fix is to co-locate data that's used together by sharding it on the same key. In a store, shard orders, order_items and shipments all by user_id, so one customer's whole order history lives on one shard and joins and transactions on it stay local. Spanner supports this directly with interleaved tables, which store child rows physically next to their parent row.
Splitting a shard means copying data while it's being written, then switching traffic over without losing writes. Doing this by hand under pressure is risky. Plan for it from the start: choose a scheme that moves partitions, not individual keys, or use a system (Vitess, Citus, or the distributed SQL databases below) that automates it.
For a long time the options looked like this:
Distributed SQL databases (Google Spanner, CockroachDB, YugabyteDB, TiDB) aim for both: SQL and serializable transactions across many machines, with sharding and replication handled by the database. They don't escape the CAP theorem: they choose consistency, so a partition that cuts off a majority of replicas makes that data unavailable, and every write pays for a consensus round trip, which is slow across regions.
These systems store every table and index as entries in one large sorted key space, with keys built from the table ID, index ID and primary key.
A transaction that touches several ranges uses a commit protocol layered on top of consensus, a form of two-phase commit in which every participant is itself a replicated group.
To give every transaction a timestamp that respects real-world order, across machines in many datacenters, you need clocks you can trust. Ordinary clocks synchronized with NTP are usually within a few milliseconds of each other, but the error can grow much larger, and a machine can't tell how wrong its own clock is. If server A's clock runs 10 ms ahead of server B's, a transaction on B that starts after one on A has committed can still get an earlier timestamp, and a reader could see the second change without the first.
Google's Spanner handles this with the TrueTime API.
TT.now() doesn't return a single time. It returns an interval [earliest, latest] that is guaranteed to contain the true time. Its half-width ε is the clock uncertainty, and the 2012 paper reports it varying between about 1 and 7 ms.Spanner uses that interval for commit wait:
s no earlier than TT.now().latest, so s is not before the true time.TT.now().earliest > s, meaning s is certainly in the past on every clock.s = TT.now().latest
wait until TT.now().earliest > s (about 2ε, on average)
then release locks and acknowledge the commit
Because of the wait, any transaction that starts after this one commits, on any server anywhere, sees a time later than s and gets a larger timestamp. Timestamps therefore match real-time order. This property is called external consistency, also known as strict serializability. The wait lasts about 2ε, a few milliseconds, and Spanner overlaps it with the Paxos replication it has to do anyway, so it often adds little latency. Smaller ε means shorter waits, which is why Google invests in the clock hardware.
Spanner uses locks (two-phase locking) for read-write transactions, and the timestamps let read-only transactions read a consistent snapshot at any timestamp without taking locks.
CockroachDB runs on ordinary hardware without TrueTime. It uses hybrid logical clocks (physical time combined with a logical counter) and assumes clocks are within a configured maximum offset of each other (500 ms by default). When a read finds a value with a timestamp inside its uncertainty window, so it can't tell whether that write came before or after it, it moves its own timestamp later and retries, which may restart the transaction. Nodes whose clocks drift past the limit shut themselves down. The result is serializable transactions without commit wait, at the cost of occasional retries and a weaker real-time guarantee than Spanner's.
A B-Tree updates pages in place. A small change means appending to the WAL and later writing back the whole 8 or 16 KB page that holds the row, at that page's position on disk. With writes spread over a large table, those page writes are random and each carries mostly unchanged bytes.
An LSM-tree turns every write into sequential I/O. The write is appended to the WAL and inserted into the in-memory MemTable, and the client is acknowledged. When the MemTable fills, it's written out as an SSTable in one sequential pass. Merging old versions is left to background compaction, which also writes sequentially. The cost moves to reads, which may have to check several files, and Bloom filters keep most of those checks in memory.
InnoDB stores rows in the clustered primary key index. A secondary index on email stores only email and the primary key user_id. For SELECT *, the engine searches the email index to find user_id, then searches the clustered index to fetch the row: two tree searches, and possibly a random disk read for the second. For SELECT user_id, email, both columns are already in the email index's leaf, so the query is answered from the index alone, an index-only scan, and the second search never happens. Adding more columns to the index (or using INCLUDE in PostgreSQL) can make other queries covered too, at the cost of slower writes and more space.
The range condition on created_at comes before status in the index. The engine can seek to the user_id and the start of the date range, but then has to scan every entry in that range and check status on each, including all the orders that aren't paid. An index on (user_id, status, created_at) puts both equality columns first, so the engine seeks directly to user_id = ?, status = 'PAID' and scans only the date range it needs.
Each new row has a larger key than every earlier row, so it always falls in the last range. All inserts go to one shard, which runs out of CPU, I/O or connections, while the shards holding older ranges get no writes. Adding shards doesn't help, because new writes still go only to the newest range. Hash partitioning on the key (or on a different key, such as user_id) spreads inserts over all shards, at the cost of making range queries on that key go to every shard.
It guarantees external consistency: if transaction T2 starts after T1 has committed in real time, T2 gets a larger timestamp, even if they run on servers on different continents. The leader picks T1's timestamp s at or above TT.now().latest, then waits until TT.now().earliest > s before releasing locks and acknowledging. After that, every clock in the system is past s, so any later transaction's timestamp exceeds it. The wait only works because TrueTime's interval is guaranteed to contain the true time; ordinary clocks have no such bound, so a system without one can't know how long to wait. The wait is about twice the uncertainty, a few milliseconds, which is why keeping ε small matters.