{"id":"database-internals","title":"Database internals","sections":[{"id":"overview","title":"Overview","html":"<p>This chapter answers five questions:</p>\n<ul>\n<li>How does a database lay data out on disk, and why do the two main designs, B-trees and LSM-trees, make opposite trade-offs?</li>\n<li>What does one lookup cost in disk reads, and what does one small write really cost?</li>\n<li>How are clustered, secondary and covering indexes laid out, and why does the order of columns in a composite index matter?</li>\n<li>Which ways of splitting rows across shards exist, and which sharding mistakes hurt most?</li>\n<li>How do distributed SQL databases such as Spanner and CockroachDB shard and replicate data for you, and how do they put transactions in order when every machine's clock is slightly wrong?</li>\n</ul>\n<p>This chapter builds on two earlier ones. <a href=\"/learn/relational-databases\">Relational databases</a> covers ACID transactions, isolation levels, replication, federation, the basics of sharding and SQL tuning. <a href=\"/learn/nosql-databases\">NoSQL and choosing a database</a> covers the NoSQL families, BASE, and how to choose between SQL and NoSQL. Those chapters describe what a database promises. This one looks at the machinery that keeps those promises.</p>\n<p>We'll keep using <strong>Crumb</strong>, the recipe site from the <a href=\"/learn/scalability\">Scalability</a> chapter. Crumb keeps users, recipes, comments, ratings and paid cooking-class bookings in PostgreSQL. It logs every page view in a separate write-heavy store. Its main tables have started to outgrow one server, so it has begun sharding them by user ID. Each section below asks what happens underneath one of those choices.</p>","headings":[]},{"id":"storage-engines-how-bytes-reach-the-disk","title":"Storage engines: how bytes reach the disk","html":"<h3 id=\"storage-engines-how-bytes-reach-the-disk\">Storage engines: how bytes reach the disk</h3>\n<p>A <strong>storage engine</strong> is the part of a database that writes rows to disk and finds them again. The layers above it parse SQL and plan queries. The engine does the actual reading and writing.</p>\n<p>Every storage engine is shaped by one fact: <strong>sequential I/O is much cheaper than random I/O</strong>. Sequential I/O reads or writes blocks that sit next to each other, one after another. Random I/O jumps between blocks scattered across the disk.</p>\n<p>On a spinning disk the gap is obvious, because each jump means moving the read head. SSDs have no moving parts, but the gap is still there. A flash drive can't overwrite a page in place. It writes the new data to a fresh page and erases old blocks in the background. So many small random writes make the drive copy data around internally, which slows it down and wears it out. Large sequential writes are the cheapest thing you can ask any drive to do.</p>\n<p>Two families of engines respond to this fact in different ways.</p>\n<figure class=\"diagram\" data-text=\"B-Tree engine InnoDB, Postgres -> Overwrite pages in place (update); LSM-tree engine RocksDB, Cassandra -> Append sorted files, merge later (update)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 560 236\" width=\"560\" role=\"img\" aria-label=\"Two ways to lay data out on disk\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"160\" y1=\"76\" x2=\"160\" y2=\"167\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"480\" y1=\"76\" x2=\"480\" y2=\"167\" marker-end=\"url(#dg-arrow)\"></line><path class=\"dg-node dg-store\" d=\"M92,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M92,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"160\" y=\"41.5\">B-Tree engine</tspan><tspan x=\"160\" y=\"57.5\">InnoDB, Postgres</tspan></text><path class=\"dg-node dg-store\" d=\"M412,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M412,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"480\" y=\"41.5\">LSM-tree engine</tspan><tspan x=\"480\" y=\"57.5\">RocksDB, Cassandra</tspan></text><rect class=\"dg-node dg-box\" x=\"92\" y=\"172\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"160\" y=\"194.5\">Overwrite pages</tspan><tspan x=\"160\" y=\"210.5\">in place</tspan></text><rect class=\"dg-node dg-box\" x=\"412\" y=\"172\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"480\" y=\"194.5\">Append sorted</tspan><tspan x=\"480\" y=\"210.5\">files, merge later</tspan></text><rect class=\"dg-edge-bg\" x=\"136.4\" y=\"112.5\" width=\"47.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"160\" y=\"125.5\" text-anchor=\"middle\">update</text><rect class=\"dg-edge-bg\" x=\"456.4\" y=\"112.5\" width=\"47.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"480\" y=\"125.5\" text-anchor=\"middle\">update</text></svg></div><figcaption>Two ways to lay data out on disk</figcaption></figure>\n<ul>\n<li>A <strong>B-tree</strong> keeps one sorted copy of the data in fixed-size pages. To change a row, it rewrites the page that holds it.</li>\n<li>A <strong>log-structured merge-tree (LSM-tree)</strong> never changes data that is already on disk. It writes new sorted files and merges them in the background.</li>\n</ul>\n<h4 id=\"b-trees-the-default-for-transactional-databases\">B-Trees: the default for transactional databases</h4>\n<p>Rudolf Bayer and Edward McCreight invented the B-tree while working at Boeing Research Labs. According to <a href=\"https://en.wikipedia.org/wiki/B-tree\" target=\"_blank\" rel=\"noopener noreferrer\">Wikipedia</a>, they designed it \"to efficiently manage index pages for large random-access files\". Their paper first circulated in 1970 and was published in <em>Acta Informatica</em> in 1972. A variant called the <strong>B+ tree</strong> is what MySQL's InnoDB engine, PostgreSQL's indexes, SQLite and SQL Server use.</p>\n<h5 id=\"how-a-b-tree-is-organized\">How a B-tree is organized</h5>\n<p>Say Crumb's <code>ratings</code> table has grown to a billion rows, and the database needs the row for one rating. Here is how a B+ tree finds it.</p>\n<ol>\n<li><strong>The file is split into fixed-size pages.</strong> A page is the unit the engine reads and writes. InnoDB's pages are 16 KB and PostgreSQL's are 8 KB, as <a href=\"/learn/relational-databases#tighten-up-the-schema\">Relational databases</a> notes.</li>\n<li><strong>Each interior page points to many children.</strong> An interior page holds keys and pointers to the pages one level down. The number of children per page is the <strong>fan-out</strong>. With 8-byte keys and 6- to 8-byte pointers, a 16 KB page has room for around a thousand children. Real pages are partly empty and keys are often longer, so a few hundred is a more typical working figure.</li>\n<li><strong>So the tree stays shallow.</strong> Each level multiplies the number of reachable entries by the fan-out. With a fan-out of 1,000, three levels reach 1,000³, which is <strong>a billion</strong> entries. Four levels reach a trillion. The root page and the level below it are read constantly, so they stay in the <strong>buffer pool</strong>, the database's cache of pages in memory. Finding one rating among a billion then costs about one or two disk reads.</li>\n<li><strong>The leaves are linked.</strong> In a B+ tree, interior pages hold only keys, which are used for routing. The data, or pointers to it, lives only in the leaf pages, and each leaf links to its neighbors. A range query such as <code>WHERE recipe_id BETWEEN 100 AND 300</code> walks down the tree once to the first matching leaf. Then it follows the links sideways until it passes the end of the range.</li>\n</ol>\n<figure class=\"diagram\" data-text=\"Root page (in memory) -> Interior page; Root page (in memory) -> Interior page; Interior page -> Leaf keys 1–100; Interior page -> Leaf keys 101–200; Interior page -> Leaf keys 201–300; Leaf keys 1–100 <-> Leaf keys 101–200 (next / prev); Leaf keys 101–200 <-> Leaf keys 201–300 (next / prev)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 640 349\" width=\"640\" role=\"img\" aria-label=\"A B+ Tree: route down, then scan along the leaves\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"281.3\" y1=\"71\" x2=\"198.7\" y2=\"133\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"358.7\" y1=\"71\" x2=\"441.3\" y2=\"133\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"145\" y1=\"189\" x2=\"100\" y2=\"270\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"190\" y1=\"189\" x2=\"280\" y2=\"270\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"495\" y1=\"189\" x2=\"540\" y2=\"270\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"153\" y1=\"306\" x2=\"247\" y2=\"306\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"393\" y1=\"306\" x2=\"487\" y2=\"306\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-box\" x=\"252\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"38.5\">Root page</tspan><tspan x=\"320\" y=\"54.5\">(in memory)</tspan></text><rect class=\"dg-node dg-box\" x=\"92\" y=\"138\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"160\" y=\"166.5\">Interior page</tspan></text><rect class=\"dg-node dg-box\" x=\"412\" y=\"138\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"480\" y=\"166.5\">Interior page</tspan></text><path class=\"dg-node dg-store\" d=\"M12,282 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M12,282 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"305.5\">Leaf</tspan><tspan x=\"80\" y=\"321.5\">keys 1–100</tspan></text><path class=\"dg-node dg-store\" d=\"M252,282 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M252,282 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"305.5\">Leaf</tspan><tspan x=\"320\" y=\"321.5\">keys 101–200</tspan></text><path class=\"dg-node dg-store\" d=\"M492,282 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,282 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"305.5\">Leaf</tspan><tspan x=\"560\" y=\"321.5\">keys 201–300</tspan></text><rect class=\"dg-edge-bg\" x=\"160.9\" y=\"297\" width=\"78.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"200\" y=\"310\" text-anchor=\"middle\">next / prev</text><rect class=\"dg-edge-bg\" x=\"400.9\" y=\"297\" width=\"78.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"440\" y=\"310\" text-anchor=\"middle\">next / prev</text></svg></div><figcaption>A B+ Tree: route down, then scan along the leaves</figcaption></figure>\n<h5 id=\"the-cost-of-updating-in-place\">The cost of updating in place</h5>\n<p>Now a Crumb user edits a comment, a change of about 100 bytes. InnoDB can't write just those 100 bytes. It does three things:</p>\n<ol>\n<li>It reads the 16 KB page that holds the row into the buffer pool, if the page isn't there already.</li>\n<li>It changes the row in memory.</li>\n<li>Later, it writes the whole 16 KB page back to its place on disk.</li>\n</ol>\n<p>If a page is full when a new row arrives, the engine <strong>splits</strong> it into two pages, and the parent page has to change too.</p>\n<p>A crash can happen between any of these steps. To survive one, B-tree engines keep a <strong>write-ahead log (WAL)</strong>, an append-only file that records each change before the data pages are touched. <a href=\"/learn/relational-databases#durability-committed-means-saved\">Relational databases</a> explains how the WAL makes committed transactions durable. MySQL calls this file the redo log, and PostgreSQL calls it the WAL. After a crash, the engine replays the log to redo any changes that hadn't reached the data pages.</p>\n<p>There is one more risk. A power cut can interrupt a 16 KB page write halfway, leaving a <strong>torn page</strong> that is part old data and part new. A log record of one row change can't repair that. So engines protect the pages themselves:</p>\n<ul>\n<li>InnoDB first writes each page to a <strong>doublewrite buffer</strong>. If the server fails in the middle of a page write, InnoDB \"can find a good copy of the page from the doublewrite buffer during crash recovery\" (<a href=\"https://dev.mysql.com/doc/refman/8.4/en/innodb-doublewrite-buffer.html\" target=\"_blank\" rel=\"noopener noreferrer\">MySQL documentation</a>).</li>\n<li>PostgreSQL's <code>full_page_writes</code> setting, which is on by default, writes \"the entire content of each disk page to WAL during the first modification of that page after a checkpoint\" (<a href=\"https://www.postgresql.org/docs/current/runtime-config-wal.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL documentation</a>).</li>\n</ul>\n<p>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. Writing more bytes to disk than the application asked to change is called <strong>write amplification</strong>. When many writes are spread across a large table, the page writes land all over the disk, so they are random I/O. That is where B-tree engines run out of disk throughput first.</p>\n<h4 id=\"lsm-trees-built-for-heavy-writes\">LSM-trees: built for heavy writes</h4>\n<p>In 1996, Patrick O'Neil, Edward Cheng, Dieter Gawlick and Elizabeth O'Neil published <em>The Log-Structured Merge-Tree (LSM-Tree)</em>. Its idea is to never update data in place on disk. Writes are collected in memory, written out as new sorted files, and merged with older files in the background.</p>\n<p>LSM-trees are used by LevelDB, RocksDB, Cassandra, ScyllaDB, HBase, Google Bigtable, and CockroachDB (through Pebble, its RocksDB-like engine).</p>\n<p>Crumb's page-view log shows why. Every page view is a small write, thousands arrive each second, and they are spread across many recipes. In a B-tree, each write would dirty a different page, and the engine would spend its disk time rewriting 16 KB pages to record a few bytes each. An LSM-tree turns the same stream into a few large sequential writes.</p>\n<figure class=\"diagram\" data-text=\"Write -> WAL (append only) (1. append); Write -> MemTable (sorted, in RAM) (2. insert); MemTable (sorted, in RAM) -> Level 0 SSTables (3. flush when full); Level 0 SSTables -> Level 1+ SSTables (4. compaction)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 640 205\" width=\"640\" role=\"img\" aria-label=\"LSM-tree write path\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"84.3\" x2=\"247\" y2=\"60.3\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"151\" y1=\"119.8\" x2=\"247\" y2=\"143.8\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"391\" y1=\"162\" x2=\"487\" y2=\"162\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"560\" y1=\"128\" x2=\"560\" y2=\"78\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"78\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"106.5\">Write</tspan></text><path class=\"dg-node dg-store\" d=\"M252,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M252,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"41.5\">WAL</tspan><tspan x=\"320\" y=\"57.5\">(append only)</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"136\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"158.5\">MemTable</tspan><tspan x=\"320\" y=\"174.5\">(sorted, in RAM)</tspan></text><path class=\"dg-node dg-store\" d=\"M492,138 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,138 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"161.5\">Level 0</tspan><tspan x=\"560\" y=\"177.5\">SSTables</tspan></text><path class=\"dg-node dg-store\" d=\"M492,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"41.5\">Level 1+</tspan><tspan x=\"560\" y=\"57.5\">SSTables</tspan></text><rect class=\"dg-edge-bg\" x=\"166.1\" y=\"63.3\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"199\" y=\"76.3\" text-anchor=\"middle\">1. append</text><rect class=\"dg-edge-bg\" x=\"166.1\" y=\"122.8\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"199\" y=\"135.8\" text-anchor=\"middle\">2. insert</text><rect class=\"dg-edge-bg\" x=\"378.2\" y=\"109\" width=\"121.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"122\" text-anchor=\"middle\">3. flush when full</text><rect class=\"dg-edge-bg\" x=\"514.7\" y=\"94\" width=\"90.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"560\" y=\"107\" text-anchor=\"middle\">4. compaction</text></svg></div><figcaption>LSM-tree write path</figcaption></figure>\n<h5 id=\"writing\">Writing</h5>\n<ol>\n<li><strong>Append to the WAL.</strong> Each write (<code>PUT key value</code> or <code>DELETE key</code>) is appended to a log on disk, so it survives a crash. Appends are sequential.</li>\n<li><strong>Insert into the MemTable.</strong> The write also goes into an in-memory sorted structure called the <strong>MemTable</strong>. In RocksDB the default MemTable is a skip list, a sorted linked list with extra shortcut links (<a href=\"https://github.com/facebook/rocksdb/wiki/Memtable\" target=\"_blank\" rel=\"noopener noreferrer\">RocksDB wiki</a>). Once both steps are done, the database acknowledges the write.</li>\n<li><strong>Flush to an SSTable.</strong> When the MemTable reaches a size limit (64 MB by default in RocksDB), it is frozen and written out in one sequential pass as a <strong>sorted string table (SSTable)</strong>. An SSTable is a file of key-value pairs in key order, with a small index and a Bloom filter (explained below). SSTables are <strong>immutable</strong>: once written, they never change. The WAL segment for that MemTable can then be dropped.</li>\n<li><strong>Updates and deletes are new entries.</strong> An update is simply a newer entry for the same key. A delete writes a marker called a <strong>tombstone</strong>. The old values stay in older files until compaction removes them.</li>\n</ol>\n<h5 id=\"reading-bloom-filters-and-compaction\">Reading, Bloom filters and compaction</h5>\n<p>A key's newest value may be in the MemTable or in any SSTable. So a read checks the newest data first:</p>\n<ol>\n<li>Look in the MemTable.</li>\n<li>Then check SSTables from newest to oldest, and stop at the first match. A tombstone means the key was deleted.</li>\n</ol>\n<p>Checking many files to find one key is called <strong>read amplification</strong>. Two things keep it down.</p>\n<p><strong>Bloom filters.</strong> Each SSTable has a Bloom filter, usually kept in memory. A Bloom filter is a small bit array that answers the question \"is this key in this file?\" with either \"definitely not\" or \"probably yes\". When it says \"definitely not\", the engine skips the file without reading it. RocksDB's <a href=\"https://github.com/facebook/rocksdb/wiki/RocksDB-Bloom-Filter\" target=\"_blank\" rel=\"noopener noreferrer\">Bloom filter guide</a> gives \"9.9 bits per key (1% false positive rate)\". So a point lookup usually reads only the one file that holds the key. Bloom filters don't help range scans, which still have to read every file whose key range overlaps the range asked for.</p>\n<p><strong>Compaction.</strong> Background threads read several SSTables and merge them in key order, like the merge step of merge sort. They keep only the newest version of each key, drop tombstones once no older value could be hiding behind them, and write new SSTables. Fewer files means fewer places to look and less disk spent on old versions.</p>\n<p>There are two common compaction strategies:</p>\n<ul>\n<li><strong>Size-tiered.</strong> When there are several SSTables of similar size, merge them into one bigger file. Cassandra's documentation says it \"is the default compaction strategy if no other strategy is specified\" (<a href=\"https://cassandra.apache.org/doc/latest/cassandra/managing/operating/compaction/stcs.html\" target=\"_blank\" rel=\"noopener noreferrer\">Cassandra STCS</a>). It rewrites data fewer times, so it suits write-heavy loads. The costs: versions of one row \"may be spread over many SSTables\", and a merge needs disk space for the old and new files at the same time, so operators leave a lot of disk free.</li>\n<li><strong>Leveled.</strong> Data is arranged in levels L0, L1, L2 and so on. In RocksDB, where this is the default style, \"each level is 10 times larger than the previous one\" (<a href=\"https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide\" target=\"_blank\" rel=\"noopener noreferrer\">RocksDB tuning guide</a>). Below L0, the files within one level don't overlap in key range, so a lookup reads at most one file per level. Reads and disk usage are more predictable. The price is more rewriting, because data is merged again each time it moves down a level.</li>\n</ul>\n<p>RocksDB's <a href=\"https://github.com/facebook/rocksdb/wiki/Compaction\" target=\"_blank\" rel=\"noopener noreferrer\">compaction overview</a> sums up the trade-off. Leveled compaction \"minimizes space amplification at the cost of read and write amplification\". Tiered compaction \"minimizes write amplification at the cost of read and space amplification\".</p>\n<h5 id=\"b-tree-vs-lsm-tree\">B-Tree vs LSM-tree</h5>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th></th><th>B-Tree (InnoDB, PostgreSQL)</th><th>LSM-tree (RocksDB, Cassandra)</th></tr></thead><tbody><tr><td>Good at</td><td>Predictable reads and range scans</td><td>High write rates</td></tr><tr><td>How a write lands</td><td>Log append now, page overwrite later</td><td>Log append and MemTable insert now; files written sequentially later</td></tr><tr><td>Write amplification</td><td>A whole page per small change, plus the log</td><td>Data rewritten by each compaction, but always sequentially</td></tr><tr><td>Read cost</td><td>One path down the tree, often one or two disk reads</td><td>MemTable plus several files; Bloom filters skip most of them</td></tr><tr><td>Space overhead</td><td>Partly empty pages after splits and deletes</td><td>Old versions until compacted; low with leveled, higher with size-tiered</td></tr><tr><td>Latency spikes</td><td>Checkpoints can cause bursts of page flushes</td><td>Heavy compaction can compete with normal reads and writes</td></tr></tbody></table></div>\n<p>Neither design wins everywhere. Plenty of write-heavy systems run well on PostgreSQL, and RocksDB serves read-heavy workloads too. Benchmark with your own workload before you switch.</p>","headings":[{"id":"storage-engines-how-bytes-reach-the-disk","title":"Storage engines: how bytes reach the disk","depth":3},{"id":"b-trees-the-default-for-transactional-databases","title":"B-Trees: the default for transactional databases","depth":4},{"id":"lsm-trees-built-for-heavy-writes","title":"LSM-trees: built for heavy writes","depth":4}]},{"id":"indexes-getting-query-performance-right","title":"Indexes: getting query performance right","html":"<h3 id=\"indexes-getting-query-performance-right\">Indexes: getting query performance right</h3>\n<p><a href=\"/learn/relational-databases#use-good-indexes\">Relational databases</a> explains what an index is, which columns to index, and what indexes cost. This section looks at how indexes are laid out inside the engine. The layout explains why two queries that look almost the same can run at very different speeds.</p>\n<h4 id=\"clustered-secondary-and-covering-indexes\">Clustered, secondary and covering indexes</h4>\n<p>When someone signs in to Crumb, the app runs <code>SELECT * FROM users WHERE email = ?</code>. Here is what that query does in InnoDB.</p>\n<figure class=\"diagram\" data-text=\"WHERE email = ? -> Secondary index email → user_id (1. search email); Secondary index email → user_id -> Clustered index user_id → row (2. look up user_id); Clustered index user_id → row -> Full row (name, address…) (leaf holds row)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 -15 640 239\" width=\"640\" role=\"img\" aria-label=\"A secondary index lookup in InnoDB\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"42\" x2=\"247\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"391\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"560\" y1=\"76\" x2=\"560\" y2=\"155\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">WHERE email = ?</tspan></text><path class=\"dg-node dg-store\" d=\"M252,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M252,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"41.5\">Secondary index</tspan><tspan x=\"320\" y=\"57.5\">email → user_id</tspan></text><path class=\"dg-node dg-store\" d=\"M492,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"41.5\">Clustered index</tspan><tspan x=\"560\" y=\"57.5\">user_id → row</tspan></text><rect class=\"dg-node dg-box\" x=\"492\" y=\"160\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"182.5\">Full row</tspan><tspan x=\"560\" y=\"198.5\">(name, address…)</tspan></text><rect class=\"dg-edge-bg\" x=\"147.5\" y=\"-11\" width=\"103\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"199\" y=\"2\" text-anchor=\"middle\">1. search email</text><rect class=\"dg-edge-bg\" x=\"378.2\" y=\"-11\" width=\"121.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"2\" text-anchor=\"middle\">2. look up user_id</text><rect class=\"dg-edge-bg\" x=\"511.6\" y=\"106.5\" width=\"96.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"560\" y=\"119.5\" text-anchor=\"middle\">leaf holds row</text></svg></div><figcaption>A secondary index lookup in InnoDB</figcaption></figure>\n<p><strong>Clustered index.</strong> A clustered index stores the table's rows inside the index itself, in key order.</p>\n<ul>\n<li>A table can have only one, because its rows can be stored in only one order.</li>\n<li>In InnoDB, the clustered index is normally the primary key, and its leaf pages hold whole rows. If a table has no primary key, InnoDB uses the first unique index whose columns are all <code>NOT NULL</code>. If there is none, it adds a hidden row ID column and clusters on that (<a href=\"https://dev.mysql.com/doc/refman/8.4/en/innodb-index-types.html\" target=\"_blank\" rel=\"noopener noreferrer\">MySQL documentation</a>).</li>\n<li>PostgreSQL works differently. Rows live in an unordered <strong>heap</strong>, and every index, including the primary key's, points into the heap. PostgreSQL's <code>CLUSTER</code> command sorts the heap by an index once, but \"Clustering is a one-time operation\": rows inserted or updated later are not kept in order (<a href=\"https://www.postgresql.org/docs/current/sql-cluster.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL documentation</a>).</li>\n</ul>\n<p><strong>Secondary index.</strong> A secondary index is an index on other columns, such as <code>CREATE INDEX idx_email ON users(email)</code>.</p>\n<ul>\n<li>In InnoDB, \"each record in a secondary index contains the primary key columns for the row, as well as the columns specified for the secondary index\". The leaf holds no other part of the row.</li>\n<li>So Crumb's sign-in query is two tree searches. The first goes down the email index and finds <code>user_id = 42</code>. The second goes down the clustered index to fetch the row for user 42.</li>\n<li>In PostgreSQL, the second step is a fetch from the heap instead.</li>\n<li>Because every secondary index stores the primary key, a long primary key makes every secondary index bigger. MySQL's documentation advises that \"it is advantageous to have a short primary key.\"</li>\n</ul>\n<p><strong>Covering index.</strong> If every column a query needs is already in the index, the database answers from the index alone and skips the second lookup. This is called an <strong>index-only scan</strong>, and the index is said to <strong>cover</strong> the query.</p>\n<ul>\n<li><code>SELECT user_id, email FROM users WHERE email = ?</code> is covered by <code>idx_email</code> in InnoDB. The leaf already holds <code>email</code> and the primary key <code>user_id</code>.</li>\n<li>To cover more columns, add them to the index. You can build a composite index on <code>(email, display_name)</code>. In PostgreSQL and SQL Server you can also write <code>INCLUDE (display_name)</code>, which stores the column in the leaves without making it part of the search key (<a href=\"https://www.postgresql.org/docs/current/indexes-index-only-scans.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL documentation</a>).</li>\n<li>The speed-up depends on whether the second lookup would have gone to disk. For a query that returns many rows, skipping one random read per row can be a large win.</li>\n<li>PostgreSQL adds one condition. It must check that each row is visible to the current transaction, and it can skip the heap only for pages that its cleanup process, vacuum, has marked as all-visible. Its documentation says an index-only scan \"will be a win only if a significant fraction of the table's heap pages have their all-visible map bits set.\"</li>\n</ul>\n<p>The trade-off: every index slows down writes and takes space, and wide covering indexes do so more.</p>\n<h4 id=\"the-leftmost-prefix-rule\">The leftmost prefix rule</h4>\n<p>A <strong>composite index</strong> is an index on several columns. Crumb's <code>bookings</code> table records paid cooking-class bookings, and it has this index:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">CREATE</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> INDEX</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\"> idx_bookings</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> ON</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> bookings (user_id, </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">status</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">, created_at);</span></span></code></pre>\n<p>The entries are sorted by <code>user_id</code> first. Entries with the same <code>user_id</code> are sorted by <code>status</code>, and entries with the same <code>status</code> are sorted by <code>created_at</code>. It works like a phone book sorted by last name and then by first name. The index helps when a query constrains a <strong>leftmost prefix</strong> of its columns, meaning the first column, or the first two, or all three:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6A737D;--shiki-dark:#6A737D\">-- Uses all three columns</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> *</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> bookings </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> user_id </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 10</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> AND</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> status</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'PAID'</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> AND</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> created_at </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">></span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> '2026-01-01'</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D;--shiki-dark:#6A737D\">-- Uses user_id and status</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> *</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> bookings </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> user_id </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 10</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> AND</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> status</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'PAID'</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D;--shiki-dark:#6A737D\">-- Uses user_id only</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> *</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> bookings </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> user_id </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 10</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D;--shiki-dark:#6A737D\">-- Can't seek with this index, because the first column isn't constrained</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> *</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> bookings </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> status</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'PAID'</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> *</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> bookings </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> created_at </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">></span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> '2026-01-01'</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span></code></pre>\n<p>The last two are like looking up everyone named \"Maria\" in a phone book sorted by last name. The sort order doesn't help you. Some engines have workarounds. MySQL's <a href=\"https://dev.mysql.com/doc/refman/8.4/en/range-optimization.html\" target=\"_blank\" rel=\"noopener noreferrer\">skip scan</a> runs one small range scan for each distinct value of the first column, so it only pays off when that column has few distinct values. Don't design your indexes around it.</p>\n<p><strong>Put equality columns first and the range column last.</strong> A <strong>range condition</strong> is one that matches a span of values: <code>&#x3C;</code>, <code>></code>, <code>BETWEEN</code>, or <code>LIKE 'abc%'</code>. Once the index reaches a range condition, the columns after it can't narrow the search any further. Take this query:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> user_id </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 10</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> AND</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> status</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'PAID'</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> AND</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> created_at </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">></span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> '2026-01-01'</span></span></code></pre>\n<ul>\n<li>With an index on <code>(user_id, created_at, status)</code>, the database seeks to <code>user_id = 10</code> and the start of the date range. Then it scans every booking after that date, paid or not, and checks <code>status</code> on each one.</li>\n<li>With an index on <code>(user_id, status, created_at)</code>, it seeks straight to <code>user_id = 10, status = 'PAID'</code> and scans only the matching date range.</li>\n</ul>\n<p>So put the columns compared with <code>=</code> first, and the range column last.</p>","headings":[{"id":"indexes-getting-query-performance-right","title":"Indexes: getting query performance right","depth":3},{"id":"clustered-secondary-and-covering-indexes","title":"Clustered, secondary and covering indexes","depth":4},{"id":"the-leftmost-prefix-rule","title":"The leftmost prefix rule","depth":4}]},{"id":"sharding","title":"Sharding","html":"<h3 id=\"sharding\">Sharding</h3>\n<p><strong>Sharding</strong> splits a table's rows across several databases, and a <strong>shard key</strong> decides which database, or <strong>shard</strong>, each row lives on. <a href=\"/learn/relational-databases#sharding-splitting-one-table-across-databases\">Relational databases</a> covers why and when to shard, how to choose a shard key, and the main disadvantages, with Notion's move to sharded PostgreSQL as an example. This section goes deeper: the three ways to map keys to shards, and the mistakes that cause the most pain once you have shards.</p>\n<p>A <strong>router</strong> sits between the app and the shards. It can be a library in the app, or a proxy such as Vitess (for MySQL) or Citus (for PostgreSQL). It reads the shard key from each query and sends the query where it needs to go.</p>\n<figure class=\"diagram\" data-text=\"user_id = 42 -> Router (Vitess, Citus…); status = &#x27;PENDING&#x27; (no shard key) -> Router (Vitess, Citus…); Router (Vitess, Citus…) -> Shard 1 (one shard); Router (Vitess, Citus…) -> Shard 2 (scatter); Router (Vitess, Citus…) -> Shard 3 (scatter)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 640 275\" width=\"640\" role=\"img\" aria-label=\"Targeted vs scatter-gather queries\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"147.5\" y1=\"69\" x2=\"247\" y2=\"108.8\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"151\" y1=\"205.6\" x2=\"247\" y2=\"167.2\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"391\" y1=\"109.6\" x2=\"487\" y2=\"71.2\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"391\" y1=\"138\" x2=\"487\" y2=\"138\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"391\" y1=\"166.4\" x2=\"487\" y2=\"204.8\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">user_id = 42</tspan></text><rect class=\"dg-node dg-client\" x=\"12\" y=\"208\" width=\"136\" height=\"52\" rx=\"26\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"230.5\">status = 'PENDING'</tspan><tspan x=\"80\" y=\"246.5\">(no shard key)</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"112\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"134.5\">Router</tspan><tspan x=\"320\" y=\"150.5\">(Vitess, Citus…)</tspan></text><path class=\"dg-node dg-store\" d=\"M492,20 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,20 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"49.5\">Shard 1</tspan></text><path class=\"dg-node dg-store\" d=\"M492,116 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,116 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"145.5\">Shard 2</tspan></text><path class=\"dg-node dg-store\" d=\"M492,212 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,212 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"241.5\">Shard 3</tspan></text><rect class=\"dg-edge-bg\" x=\"406.1\" y=\"81.4\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"94.4\" text-anchor=\"middle\">one shard</text><rect class=\"dg-edge-bg\" x=\"412.3\" y=\"129\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"142\" text-anchor=\"middle\">scatter</text><rect class=\"dg-edge-bg\" x=\"412.3\" y=\"176.6\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"189.6\" text-anchor=\"middle\">scatter</text></svg></div><figcaption>Targeted vs scatter-gather queries</figcaption></figure>\n<p>A query that includes the shard key goes to one shard. A query without it has to ask every shard.</p>\n<h4 id=\"partitioning-strategies\">Partitioning strategies</h4>\n<p>There are three main ways to decide which shard owns a key.</p>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th>Strategy</th><th>How a key finds its shard</th><th>Strength</th><th>Weakness</th></tr></thead><tbody><tr><td>Range</td><td>Each shard owns a continuous span of keys</td><td>Range queries on the key touch few shards</td><td>Increasing keys all land on the last shard</td></tr><tr><td>Hash</td><td>Hash the key, then map the hash to a shard</td><td>Spreads keys evenly</td><td>Range queries on the key touch every shard</td></tr><tr><td>Directory</td><td>Look the key up in a mapping table</td><td>Full control over placement</td><td>The directory must stay available and consistent</td></tr></tbody></table></div>\n<h5 id=\"range-partitioning\">Range partitioning</h5>\n<p>Each shard holds a continuous range of the key. For example, user IDs 1 to 1,000,000 go on shard 1 and 1,000,001 to 2,000,000 on shard 2. Or each shard holds one month of data.</p>\n<ul>\n<li><strong>Good:</strong> a range query on the key, such as <code>WHERE id BETWEEN 500 AND 600</code>, touches one shard or a few neighboring ones.</li>\n<li><strong>Bad:</strong> if the key always increases, every new row goes to the last range. Auto-increment IDs, timestamps and <a href=\"/learn/unique-ids#snowflake-style-ids\">Snowflake-style IDs</a> all behave this way. If Crumb range-partitioned its page-view log by timestamp, every new view would land on the newest shard while the others sat idle. A shard that gets far more traffic than the rest is a <strong>hot spot</strong>. <a href=\"/learn/nosql-databases#sorted-keys-and-hotspots\">NoSQL and choosing a database</a> shows the same problem with sorted row keys in Bigtable and HBase.</li>\n</ul>\n<h5 id=\"hash-partitioning\">Hash partitioning</h5>\n<p>Hash the shard key, and use the hash to pick a shard. A hash function turns a key into a number that looks random, and the same key always gives the same number.</p>\n<ul>\n<li><strong>Good:</strong> keys spread evenly, so increasing keys no longer pile onto one shard.</li>\n<li><strong>Bad:</strong> neighboring keys hash to unrelated shards. A range query on the key, such as <code>WHERE user_id BETWEEN 10 AND 100</code>, has to go to every shard.</li>\n</ul>\n<p>The simplest version is <code>shard = hash(user_id) mod N</code>, where N is the number of shards. Its problem is changing N. Going from 4 shards to 5 changes the shard for about 80% of keys. A key stays put only when <code>hash mod 4</code> equals <code>hash mod 5</code>, which is true for 4 out of every 20 hash values. So most of the data has to move.</p>\n<p><a href=\"/learn/consistent-hashing\">Consistent hashing</a> fixes this. It places shards and keys on a ring. A new shard takes over only the keys next to it on the ring, about 1/N of the total, and virtual nodes keep the load even. Many systems get the same effect in a simpler way. They split the hash space into many more fixed partitions than there are servers, say 1,024, and move whole partitions between servers. Notion's 480 logical shards, described in <a href=\"/learn/relational-databases#a-real-example-notion-shards-postgresql\">Relational databases</a>, work this way.</p>\n<h5 id=\"directory-partitioning\">Directory partitioning</h5>\n<p>A lookup service maps each key, or each tenant, to a shard: <code>tenant A → shard 3</code>, <code>tenant B → shard 1</code>. The mapping lives in a small, strongly consistent store such as etcd, ZooKeeper or a replicated SQL table, and the routers cache it.</p>\n<ul>\n<li><strong>Good:</strong> you have full control over placement. A very large customer can get a shard of its own, and you can move one tenant without touching the rest.</li>\n<li><strong>Bad:</strong> the directory is one more system to keep available and consistent. Every query needs the mapping, either from the cache or from a lookup.</li>\n</ul>\n<h4 id=\"sharding-mistakes-that-hurt\">Sharding mistakes that hurt</h4>\n<h5 id=\"picking-a-shard-key-with-a-hot-spot\">Picking a shard key with a hot spot</h5>\n<p>The range case above is the classic hot spot. Hash partitioning has its own version: a key that is hot by nature. <a href=\"/learn/relational-databases#disadvantages-of-sharding\">Relational databases</a> gives the Crumb example of a celebrity chef with two million followers. If Crumb shards by <code>author_id</code>, all the traffic for her recipes goes to one shard. A better hash function doesn't help, because her ID still hashes to exactly one place. The same happens when you shard by <code>tenant_id</code> and one tenant produces half the traffic.</p>\n<p>So before choosing a shard key, look at how traffic is distributed across keys, as well as how many distinct keys there are. If a few keys will be very hot, plan for them. <a href=\"/learn/consistent-hashing#hot-keys\">Consistent hashing</a> lists the usual fixes, such as caching hot reads close to the app and splitting one hot key into several.</p>\n<h5 id=\"scatter-gather-queries\">Scatter-gather queries</h5>\n<p>Crumb shards <code>bookings</code> by <code>user_id</code>. Its support team wants the ten newest pending bookings:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> *</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> bookings </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> status</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'PENDING'</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> ORDER BY</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> created_at </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">DESC</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> LIMIT</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 10</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span></code></pre>\n<p>The query has no <code>user_id</code>, so the router can't tell which shard holds the answer. It has to <strong>scatter</strong> the query and <strong>gather</strong> the results:</p>\n<ol>\n<li>Send the query to all N shards.</li>\n<li>Get the top 10 from each shard.</li>\n<li>Merge the 10 × N results and return the overall top 10.</li>\n</ol>\n<p>The query is only as fast as the <strong>slowest</strong> shard. Suppose 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⁵⁰ ≈ <strong>40%</strong> of the time. Every scatter query also uses capacity on every shard, so the total query load grows with the number of shards.</p>\n<p>Occasional scatter queries are fine. If a frequent query needs a different key, keep a second copy of the data that is sharded or indexed by that key. Two common forms are a <strong>global secondary index</strong>, which the database maintains across all shards, and a separate table kept up to date from a stream of changes.</p>\n<h5 id=\"cross-shard-joins-and-transactions\">Cross-shard joins and transactions</h5>\n<p>On one server, joining <code>users</code> to <code>bookings</code> is a local operation. If the two tables are sharded on unrelated keys, matching rows sit on different servers. The join then has to ship rows over the network to one place to be matched. A transaction that updates rows on two shards needs a <a href=\"/learn/distributed-transactions#two-phase-commit-and-its-blocking-problem\">distributed transaction</a> protocol such as two-phase commit, which is slower and harder to operate.</p>\n<p>The fix is to <strong>co-locate</strong> data that is used together, by sharding it on the same key. Crumb shards <code>bookings</code>, <code>payments</code> and <code>class_reviews</code> all by <code>user_id</code>. One customer's bookings and payments then live on one shard, and joins and transactions on them stay local.</p>\n<p>Spanner supports co-location directly with <strong>interleaved tables</strong>. Its <a href=\"https://docs.cloud.google.com/spanner/docs/schema-and-data-model\" target=\"_blank\" rel=\"noopener noreferrer\">documentation</a> says it \"physically colocates child rows with parent rows in storage\", and that it \"can perform joins by primary key locally, minimizing storage access and network traffic.\"</p>\n<h5 id=\"resharding-as-an-afterthought\">Resharding as an afterthought</h5>\n<p><strong>Resharding</strong> means changing how data is split, for example splitting one busy shard into two. You have to copy data while it is still being written, then switch traffic to the new shards without losing any writes. Doing this by hand, under pressure, is risky.</p>\n<p>Plan for it from the start. Choose a scheme that moves whole partitions instead of individual keys, or use a system that automates the move. Vitess, for example, copies and verifies data on the new shards while the old shards keep serving traffic. Its <a href=\"https://vitess.io/docs/reference/features/sharding/\" target=\"_blank\" rel=\"noopener noreferrer\">documentation</a> says the final switch happens \"with only a few seconds of read-only downtime.\" The distributed SQL databases in the next section split and move data on their own.</p>","headings":[{"id":"sharding","title":"Sharding","depth":3},{"id":"partitioning-strategies","title":"Partitioning strategies","depth":4},{"id":"sharding-mistakes-that-hurt","title":"Sharding mistakes that hurt","depth":4}]},{"id":"distributed-sql","title":"Distributed SQL","html":"<h3 id=\"distributed-sql\">Distributed SQL</h3>\n<p><a href=\"/learn/nosql-databases#the-lines-have-blurred\">NoSQL and choosing a database</a> describes the old choice. A single-node relational database gives you SQL, joins and ACID transactions, but scaling writes past one machine means sharding by hand, with all the problems above. A NoSQL store scales out on its own, but traditionally gave up multi-row transactions and joins.</p>\n<p><strong>Distributed SQL</strong> databases, such as Google Spanner, CockroachDB, YugabyteDB and TiDB, aim for both. They offer SQL and serializable transactions across many machines, and the database itself handles sharding and replication.</p>\n<p>They don't escape the <a href=\"/learn/cap-and-consistency\">CAP theorem</a>. They choose consistency. If a network partition cuts a piece of data off from a majority of its replicas, that data becomes unavailable. And every write pays for a round of agreement between replicas, which is slow when the replicas are in different regions.</p>\n<figure class=\"diagram\" data-text=\"SQL layer (stateless nodes) -> Sorted key-value store, in ranges (rows → keys); Sorted key-value store, in ranges -> Raft or Paxos group per range (replicate)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 400 368\" width=\"400\" role=\"img\" aria-label=\"How distributed SQL databases are layered\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"320\" y1=\"71\" x2=\"320\" y2=\"150\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"320\" y1=\"220\" x2=\"320\" y2=\"299\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-box\" x=\"252\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"38.5\">SQL layer</tspan><tspan x=\"320\" y=\"54.5\">(stateless nodes)</tspan></text><path class=\"dg-node dg-store\" d=\"M252,162 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M252,162 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"185.5\">Sorted key-value</tspan><tspan x=\"320\" y=\"201.5\">store, in ranges</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"304\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"326.5\">Raft or Paxos</tspan><tspan x=\"320\" y=\"342.5\">group per range</tspan></text><rect class=\"dg-edge-bg\" x=\"280.9\" y=\"101.5\" width=\"78.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"320\" y=\"114.5\" text-anchor=\"middle\">rows → keys</text><rect class=\"dg-edge-bg\" x=\"287.1\" y=\"250.5\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"320\" y=\"263.5\" text-anchor=\"middle\">replicate</text></svg></div><figcaption>How distributed SQL databases are layered</figcaption></figure>\n<h4 id=\"ranges-and-consensus\">Ranges and consensus</h4>\n<p>These systems store every table and every index as entries in <strong>one large sorted key space</strong>. Each key is built from the table ID, the index ID and the row's primary key. So one table's rows sit next to each other, in primary key order.</p>\n<ul>\n<li>The key space is cut into contiguous chunks. CockroachDB calls them <strong>ranges</strong>, and TiDB calls them regions. In CockroachDB, a range splits in two once it passes <code>range_max_bytes</code>, which is 512 MiB by default (<a href=\"https://docs.cockroachlabs.com/docs/stable/configure-replication-zones\" target=\"_blank\" rel=\"noopener noreferrer\">CockroachDB documentation</a>).</li>\n<li>The database moves ranges between nodes to balance load, and splits busy ranges further. This is range partitioning, done automatically.</li>\n<li>Each range is copied to several nodes, usually in different zones. CockroachDB keeps 3 copies by default. The copies of one range form a <a href=\"/learn/consensus\">consensus</a> group, which uses a protocol that lets a majority of copies agree on each change. CockroachDB, TiDB and YugabyteDB use Raft, and Spanner uses Paxos. A write commits once a majority of the range's copies have it.</li>\n</ul>\n<p>A transaction that touches several ranges uses a commit protocol layered on top of consensus. It is a form of two-phase commit in which every participant is itself a replicated group. So a single machine failure doesn't leave the transaction stuck.</p>\n<h4 id=\"ordering-transactions-with-clocks-spanners-truetime\">Ordering transactions with clocks: Spanner's TrueTime</h4>\n<p>A database spread across many datacenters still needs to give each transaction a timestamp. Ideally those timestamps match real-world order: if transaction B starts after transaction A has committed, B should get a later timestamp.</p>\n<p>Ordinary clocks make this hard. Servers keep their clocks in step with NTP, the Network Time Protocol. According to <a href=\"https://en.wikipedia.org/wiki/Network_Time_Protocol\" target=\"_blank\" rel=\"noopener noreferrer\">Wikipedia</a>, NTP \"can usually maintain time to within tens of milliseconds over the public Internet\". It adds: \"Asymmetric routes and network congestion can cause errors of 100 ms or more.\" Worse, a machine can't tell how wrong its own clock is. Suppose 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. A reader could then see the second change without the first.</p>\n<p>Google's <strong>Spanner</strong> handles this with an API called <strong>TrueTime</strong>, described in its <a href=\"https://research.google/pubs/spanner-googles-globally-distributed-database-2/\" target=\"_blank\" rel=\"noopener noreferrer\">2012 paper</a>.</p>\n<ul>\n<li>Each datacenter has a set of time master machines. Most have GPS receivers. The rest, which Google calls \"Armageddon masters\", have atomic clocks. GPS and atomic clocks fail in different ways, so using both protects against either one failing.</li>\n<li>Every server polls several masters, and rejects any that disagree with the rest.</li>\n<li><code>TT.now()</code> doesn't return a single time. It returns an interval, <code>[earliest, latest]</code>, that is guaranteed to contain the true time. Half the width of that interval is the clock uncertainty, written ε. The paper reports that in production, ε \"is typically a sawtooth function of time, varying from about 1 to 7 ms\".</li>\n</ul>\n<p>Spanner uses that interval for <strong>commit wait</strong>:</p>\n<ol>\n<li>The transaction's coordinator picks a commit timestamp <code>s</code> no earlier than <code>TT.now().latest</code>. So <code>s</code> is not before the true time.</li>\n<li>Before it makes the commit visible and releases locks, it waits until <code>TT.now().earliest > s</code>. At that point, <code>s</code> is certainly in the past on every clock.</li>\n</ol>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>s = TT.now().latest</span></span>\n<span class=\"line\"><span>wait until TT.now().earliest > s     (at least 2ε)</span></span>\n<span class=\"line\"><span>then release locks and acknowledge the commit</span></span></code></pre>\n<p>Because of the wait, any transaction that starts after this one commits, on any server anywhere, sees a time later than <code>s</code> and gets a larger timestamp. So timestamps match real-time order. This property is called <strong>external consistency</strong>, also known as strict serializability.</p>\n<p>The paper says the expected wait is at least 2ε, which is a few milliseconds. It also says the wait \"is typically overlapped with Paxos communication\". Spanner has to wait for replication anyway, so commit wait often adds little extra latency. A smaller ε means a shorter wait, which is why Google invests in the clock hardware.</p>\n<p>Spanner uses locks (two-phase locking) for read-write transactions. The timestamps also let read-only transactions read a consistent snapshot at any timestamp without taking locks. <a href=\"/learn/cap-and-consistency#google-spanner-consistent-and-available-enough\">CAP and consistency</a> covers what Spanner's design means for availability.</p>\n<h4 id=\"cockroachdb-ordinary-clocks-with-a-safety-margin\">CockroachDB: ordinary clocks with a safety margin</h4>\n<p>CockroachDB runs on ordinary hardware without TrueTime. It uses <strong>hybrid logical clocks</strong>, which combine the machine's physical time with a logical counter that breaks ties. It also assumes every node's clock is within a configured maximum offset of the others. The default is 500 ms, and the <a href=\"https://docs.cockroachlabs.com/docs/stable/cockroach-start\" target=\"_blank\" rel=\"noopener noreferrer\">documentation</a> says that \"if observed clock offsets exceed this limit, servers will crash to minimize the likelihood of reading inconsistent data.\"</p>\n<p>That offset creates an <strong>uncertainty window</strong> around each read. Sometimes a read finds a value whose timestamp is slightly later than its own, but within the window. It can't tell whether that write really happened before it or after it. So, as the <a href=\"https://docs.cockroachlabs.com/docs/stable/architecture/transaction-layer\" target=\"_blank\" rel=\"noopener noreferrer\">transaction layer documentation</a> describes, CockroachDB pushes the reading transaction's timestamp past the uncertain value. If the transaction's earlier reads are still valid at the new timestamp, it carries on. If not, it retries at the new timestamp.</p>\n<p>The result is serializable transactions without commit wait. The cost is occasional retries, and a weaker real-time guarantee than Spanner's.</p>","headings":[{"id":"distributed-sql","title":"Distributed SQL","depth":3},{"id":"ranges-and-consensus","title":"Ranges and consensus","depth":4},{"id":"ordering-transactions-with-clocks-spanners-truetime","title":"Ordering transactions with clocks: Spanner's TrueTime","depth":4},{"id":"cockroachdb-ordinary-clocks-with-a-safety-margin","title":"CockroachDB: ordinary clocks with a safety margin","depth":4}]},{"id":"check-yourself","title":"Check yourself","html":"<h3 id=\"check-yourself\">Check yourself</h3>\n<details>\n<summary>Crumb's page-view log takes thousands of small writes a second, spread across many recipes. Why can an LSM-tree absorb that rate better than a B-tree on the same drive?</summary>\n<p>A B-tree updates pages in place. Each small write means a WAL append 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 one carries mostly unchanged bytes.</p>\n<p>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 then it is acknowledged. When the MemTable fills, it is 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. Bloom filters let most of those checks happen in memory.</p>\n</details>\n<details>\n<summary>An LSM-tree store gets slower at reads as data piles up, and its disk keeps filling with old versions. What is going on, and what are the two main compaction strategies' trade-offs?</summary>\n<p>Updates and deletes are written as new entries, so old versions and tombstones accumulate in older SSTables until compaction merges them away. More files means more places to check for each key (read amplification) and more disk spent on dead data (space amplification). If compaction falls behind the write rate, both grow.</p>\n<p>Size-tiered compaction merges files of similar size. It rewrites data fewer times, so it suits heavy writes, but one key's versions can be spread across many files, and a large merge needs free disk for the old and new files at once. Leveled compaction keeps each level's files non-overlapping, so a lookup reads at most one file per level and space use stays low. The price is more rewriting, because data is merged again each time it moves down a level.</p>\n</details>\n<details>\n<summary>In InnoDB, why can `SELECT user_id, email FROM users WHERE email = ?` be faster than `SELECT * FROM users WHERE email = ?`, given an index on `email`?</summary>\n<p>InnoDB stores rows inside the clustered primary key index. A secondary index on <code>email</code> stores only <code>email</code> and the primary key <code>user_id</code>. For <code>SELECT *</code>, the engine searches the email index to find <code>user_id</code>, then searches the clustered index to fetch the row. That is two tree searches, and the second may need a random disk read. For <code>SELECT user_id, email</code>, both columns are already in the email index's leaf. The query is answered from the index alone, as an index-only scan, and the second search never happens. Adding more columns to the index, or using <code>INCLUDE</code> in PostgreSQL, can make other queries covered too, at the cost of slower writes and more space.</p>\n</details>\n<details>\n<summary>An index on `(user_id, created_at, status)` exists, and a common query filters on `user_id = ?`, `status = 'PAID'` and `created_at > ?`. What's wrong, and what index would be better?</summary>\n<p>The range condition on <code>created_at</code> comes before <code>status</code> in the index. The engine can seek to the <code>user_id</code> and the start of the date range. But then it has to scan every entry in that range and check <code>status</code> on each one, including all the bookings that aren't paid. An index on <code>(user_id, status, created_at)</code> puts both equality columns first. The engine then seeks directly to <code>user_id = ?, status = 'PAID'</code> and scans only the date range it needs.</p>\n</details>\n<details>\n<summary>Why is range partitioning on an auto-increment ID or a timestamp a bad choice for a write-heavy table?</summary>\n<p>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 partitioning on a different key such as <code>user_id</code>, spreads inserts over all shards. The cost is that range queries on the hashed key must go to every shard.</p>\n</details>\n<details>\n<summary>Crumb's bookings are sharded by `user_id` across 50 shards. The support dashboard's main query is \"newest pending bookings\", and it is slow and spiky. Why, and what would you do?</summary>\n<p>The query has no <code>user_id</code>, so the router must send it to all 50 shards, collect each shard's top results, and merge them. It finishes only when the slowest shard answers. If each shard is slow 1% of the time, at least one of 50 is slow about 40% of the time (1 − 0.99⁵⁰). It also adds load to every shard. Since this query runs constantly, keep a second copy of the data organized by what it needs: a global secondary index on <code>(status, created_at)</code>, or a small table of pending bookings fed from a stream of changes. Then the dashboard reads one place instead of fifty.</p>\n</details>\n<details>\n<summary>What does Spanner's commit wait guarantee, and why does it depend on TrueTime's uncertainty bound?</summary>\n<p>It guarantees external consistency. If transaction T2 starts after T1 has committed in real time, T2 gets a larger timestamp, even if the two run on servers on different continents. The coordinator picks T1's timestamp <code>s</code> at or above <code>TT.now().latest</code>. Then it waits until <code>TT.now().earliest > s</code> before releasing locks and acknowledging. After that, every clock in the system is past <code>s</code>, so any later transaction's timestamp is larger. The wait only works because TrueTime's interval is guaranteed to contain the true time. Ordinary NTP clocks have no such bound, so a system without one can't know how long to wait. The wait is at least twice the uncertainty, a few milliseconds, which is why keeping ε small matters. CockroachDB, which lacks TrueTime, instead assumes a maximum clock offset and retries reads that fall inside the uncertainty window.</p>\n</details>\n<h3 id=\"further-reading\">Further reading</h3>\n<ul>\n<li><a href=\"https://www.cs.umb.edu/~poneil/lsmtree.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">The Log-Structured Merge-Tree (LSM-Tree)</a> (O'Neil, Cheng, Gawlick and O'Neil, Acta Informatica, 1996): the paper that introduced the LSM-tree.</li>\n<li><a href=\"https://dataintensive.net/\" target=\"_blank\" rel=\"noopener noreferrer\">Designing Data-Intensive Applications</a> by Martin Kleppmann (2017): chapter 3 compares B-trees and LSM-trees, and chapter 6 covers partitioning.</li>\n<li><a href=\"https://github.com/facebook/rocksdb/wiki\" target=\"_blank\" rel=\"noopener noreferrer\">RocksDB wiki</a>: clear pages on MemTables, Bloom filters, leveled and universal compaction, and tuning, from the engine's own team.</li>\n<li><a href=\"https://use-the-index-luke.com/\" target=\"_blank\" rel=\"noopener noreferrer\">Use The Index, Luke!</a> by Markus Winand: how B-tree indexes work and how to design composite indexes, across the major SQL databases.</li>\n<li><a href=\"https://research.google/pubs/spanner-googles-globally-distributed-database-2/\" target=\"_blank\" rel=\"noopener noreferrer\">Spanner: Google's Globally-Distributed Database</a> (Corbett et al., OSDI 2012): TrueTime, commit wait and external consistency.</li>\n<li><a href=\"https://dl.acm.org/doi/10.1145/3318464.3386134\" target=\"_blank\" rel=\"noopener noreferrer\">CockroachDB: The Resilient Geo-Distributed SQL Database</a> (Taft et al., SIGMOD 2020): ranges, Raft, and transactions with hybrid logical clocks on ordinary hardware.</li>\n<li><a href=\"https://vitess.io/docs/reference/features/sharding/\" target=\"_blank\" rel=\"noopener noreferrer\">Vitess sharding</a>: how a sharding layer for MySQL maps keys to shards and reshards a live database.</li>\n</ul>","headings":[{"id":"check-yourself","title":"Check yourself","depth":3},{"id":"further-reading","title":"Further reading","depth":3}]}]}