Sina Vafadar

Storage and Retrieval

Aug 7, 2026 · 26 min

Designing Data-Intensive Applications · Chapter 3 · Recall lab

Chapter 2 asked what shape your data has. Chapter 3 goes one layer down and asks what the engine actually does with it: what happens when you store data, and what the database does when you ask for it back. Two questions organise everything below. Are you appending to files or overwriting pages in place? That splits transactional storage into two schools which have been arguing since 1970. Are you fetching a few records by key or scanning millions of them? That splits storage engines into OLTP and analytics altogether, and by the end of the chapter the answer to the second question has quietly rearranged the answer to the first.

Starting point § Data Structures That Power Your Database

The world's simplest database is two Bash functions

#!/bin/bash

db_set () {
    echo "$1,$2" >> database
}

db_get () {
    grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}

A key-value store. The storage format is a text file with one comma-separated pair per line, roughly a CSV. Every db_set appends, so updating a key does not overwrite the old version: you have to look at the last occurrence of a key to find the current value, which is what tail -n 1 is doing. Step through it and watch what the file actually accumulates.

file: database
empty
An empty fileThe storage format is a text file with one comma-separated key-value pair per line, roughly a CSV. Two Bash functions, and the whole thing is a key-value store.

db_set is surprisingly good

Appending to a file is generally very efficient, and many real databases internally use a log, an append-only data file, for exactly this reason. Real ones also handle concurrency control, reclaiming disk space so the log does not grow forever, and partially written records, but the basic principle is the same.

db_get is terrible

Every lookup scans the whole file from beginning to end. The cost is O(n): double the records and a lookup takes twice as long. Real databases do the opposite of scaling like this.

Terminology. In this book log does not mean application logs, the text an application emits describing what is happening. It means the general thing, an append-only sequence of records. It need not be human-readable, and might be binary and intended only for other programs to read.

What an index is, and the trade-off it always carries

An index is additional metadata on the side that acts as a signpost to help you locate the data you want, an additional structure derived from the primary data. Adding or removing one does not change the contents of the database, only the performance of queries. If you want to search the same data in several different ways, you may need several different indexes on different parts of it.

And the trade-off, which recurs for the rest of the chapter: well-chosen indexes speed up read queries, but every index slows down writes, because the index has to be updated on every write. For writes it is hard to beat simply appending to a file, the simplest possible write operation. Which is why databases do not index everything by default: they make you choose, from knowledge of the application’s typical query patterns.

School one, part one § Hash Indexes

Keep the whole index in RAM, as byte offsets

The simplest possible indexing strategy: an in-memory hash map where every key maps to a byte offset in the data file. Append a pair, update the map with the offset you just wrote. To read, look up the offset, seek there, read the value. This is essentially what Bitcask, the default storage engine in Riak, does.

data file, append-only · byte offset, record
empty
in-memory hash map · key, byte offset
empty
ReadyThe workload this suits is one where the value for each key is updated frequently: the key is the URL of a cat video, the value is the number of times it has been played. Lots of writes, not too many distinct keys.

The constraint is that all the keys must fit in available RAM, since the hash map is kept completely in memory. The values can use more space than there is memory, because they can be loaded from disk with just one seek, and if that part of the file is already in the filesystem cache, a read requires no disk I/O at all.

The workload this suits is one where the value for each key is updated frequently. The chapter’s example: the key is the URL of a cat video and the value is the number of times it has been played, incremented on every press of play. Lots of writes, not too many distinct keys, so many writes per key and keeping all the keys in memory is feasible.

Compaction and segment merging

If we only ever append, how do we avoid running out of disk? Break the log into segments of a certain size: close a segment file when it reaches a size threshold and write subsequently to a new one. Then run compaction on those segments, throwing away duplicate keys and keeping only the most recent update for each key. Because compaction usually makes segments much smaller, you can merge several at the same time as compacting them.

segment 1 · older, frozen
mew: 1078purr: 2103cat_v1: 4mew: 1079purr: 2104cat_v1: 5mew: 1080
segment 2 · newer, frozen
purr: 2105mew: 1081cat_v1: 6mew: 1082cat_v1: 7purr: 2106
Two frozen segmentsA segment file is closed once it reaches a certain size and writes continue to a new one, so these two are now immutable. Count how many of these records are superseded versions of a key that appears again below them.

Each segment has its own in-memory hash table mapping keys to file offsets. To find a key you check the most recent segment’s hash map first, then the second-most-recent if it is absent, and so on. The merging process keeps the number of segments small, so lookups never have to check many maps.

Five things a real implementation has to get right

File formatCSV is not the best format for a log. It is faster and simpler to use a binary format that first encodes the length of a string in bytes, followed by the raw string, with no need for escaping.

Why append-only turns out to be good

  • Appending and segment merging are sequential write operations, generally much faster than random writes, especially on magnetic spinning-disk drives and to some extent preferable on SSDs too.
  • Concurrency and crash recovery are much simpler when segment files are append-only or immutable. You never face a crash mid-overwrite leaving a file with part of the old and part of the new value spliced together.
  • Merging old segments avoids fragmentation of data files over time.

Two limitations of the hash table index

  • The hash table must fit in memory. In principle you could keep one on disk, but on-disk hash maps are hard to make perform: lots of random access I/O, expensive to grow when full, and hash collisions need fiddly logic.
  • Range queries are not efficient. You cannot easily scan all keys between kitty00000 and kitty99999, you would have to look up each key individually.
School one, part two § SSTables and LSM-Trees

One small change: require the segment to be sorted by key

In the segments so far, pairs appear in the order they were written and later values take precedence over earlier ones for the same key. Beyond that the order does not matter. Now require that the sequence of key-value pairs is sorted by key, and that each key appears only once within each merged segment file, which compaction already ensures. That format is a Sorted String Table, or SSTable. At first glance sorting seems to cost us the sequential writes the whole design was built on. Hold that thought.

Advantage 1, merging is simple and efficient, even beyond memory size

The approach is the one used in mergesort: read the input files side by side, look at the first key in each, copy the lowest key to the output, repeat. The output is a new merged segment, also sorted by key. Watch what happens at handful, the key that is in both inputs.

input · older segment
handbag: 8handful: 3handicap: 1handkerchief: 5
input · newer segment
handcuffs: 2handful: 9handiwork: 7handsome: 4
output · merged segment, sorted by key
empty
ReadyRead both input files side by side, look at the first key in each, copy the lowest to the output, repeat. Both inputs may be far larger than available memory and it does not matter, because you only ever hold one record from each.

Advantage 2, the in-memory index can be sparse

You no longer need an index of all the keys in memory. Say you want handiwork and do not know its offset, but you do know the offsets of handful and handkerchief, and because of the sorting handiwork must lie between them. Jump to the lower offset and scan from there. One key every few kilobytes of segment file is sufficient, because a few kilobytes can be scanned very quickly.

sparse in-memory index · key, byte offset
handcuffs → 135handkerchief → 275handsome → 415
sstable segment on disk, sorted by key
100: handbag135: handcuffs168: handful202: handicap237: handiwork275: handkerchief311: handlebars344: handoff381: handprinted415: handsome
Lookup: handiworkThe sparse index does not contain handiwork. But it does contain handcuffs, and because the file is sorted we know handiwork must appear after it and before handkerchief. So jump to offset 135 and scan forward, 4 records here, until we find it, or do not, if the key is not in the file at all.

One key every few kilobytes is enough, because a few kilobytes can be scanned very quickly. And since a read scans a range of pairs anyway, those records can be grouped into a compressed block, with each index entry pointing at the block's start, which saves disk space and I/O bandwidth.

A footnote worth keeping: if all keys and values had a fixed size you could binary-search the segment file and skip the in-memory index entirely. In practice they are variable-length, which makes it hard to tell where one record ends and the next begins without an index.

Advantage 3, compressed blocks

Since a read has to scan over several key-value pairs in the requested range anyway, you can group those records into a block and compress it before writing to disk. Each entry of the sparse index then points at the start of a compressed block. Besides saving disk space, compression reduces I/O bandwidth use.

Constructing and maintaining SSTables

So how does the data get sorted by key in the first place, when incoming writes arrive in any order? Maintaining a sorted structure on disk is possible, that is a B-tree, but maintaining it in memory is much easier, using a well-known balanced tree such as a red-black tree or an AVL tree: insert in any order, read back in sorted order. That is the answer to the worry above, and the figure on the left is the whole write path running at once.

writes purr mew cat_v1 handoff
memtable · balanced tree, sorted by key
threshold, a few megabytes
flush, sequentially
sstable segments on disk, newest first
seg 4 seg 3 seg 2 seg 1 background merge and compaction
durability log · unsorted, discarded on flush
Every write lands in two places, and the log is thrown away the moment the memtable it protected is safely an SSTable.

The write path

ReadyFour things are happening at once in the figure. Press the button to take them one at a time.

Making an LSM-tree out of SSTables

This algorithm is essentially what LevelDB and RocksDB use, key-value storage engine libraries designed to be embedded into other applications. LevelDB can be used in Riak as an alternative to Bitcask. Similar engines are used in Cassandra and HBase, both inspired by Google’s Bigtable paper, which introduced the terms SSTable and memtable. The structure was originally described by Patrick O’Neil et al. as the Log-Structured Merge-Tree, building on earlier work on log-structured filesystems, and engines built on this principle of merging and compacting sorted files are called LSM storage engines.

Lucene uses the same idea for full-text search

A full-text index is much more complex than a key-value index but rests on a similar idea: given a word in a search query, find all the documents mentioning it. That is implemented as a key-value structure where the key is a term and the value is the postings list, the list of IDs of all documents containing the word. In Lucene, used by Elasticsearch and Solr, that term-to-postings-list mapping lives in SSTable-like sorted files, merged in the background as needed.

Performance optimizations

The LSM-tree algorithm can be slow when looking up keys that do not exist: you have to check the memtable, then the segments all the way back to the oldest, possibly reading from disk for each one, before you can be sure the key is not there. Hence Bloom filters, a memory-efficient data structure for approximating the contents of a set. It can tell you if a key does not appear in the database, saving many unnecessary disk reads for nonexistent keys.

Compaction strategies, the order and timing of the merges

Size-tiered compactionNewer and smaller SSTables are successively merged into older and larger SSTables.

Used by HBase. Cassandra supports it.

Many subtleties aside, the basic idea, keeping a cascade of SSTables that are merged in the background, is simple and effective. It keeps working well when the dataset is much bigger than available memory. Because data is stored in sorted order you can efficiently perform range queries, and because the disk writes are sequential the LSM-tree can support remarkably high write throughput.

School two § B-Trees

Fixed-size pages, overwritten in place

Log-structured indexes are gaining acceptance, but they are not the most common type. The most widely used indexing structure is quite different: the B-tree, introduced in 1970 and called “ubiquitous” less than ten years later, still the standard index implementation in almost all relational databases and plenty of nonrelational ones.

Like SSTables they keep key-value pairs sorted by key, which allows efficient lookups and range queries. That is where the similarity ends. Log-structured indexes break the database into variable-size segments, typically several megabytes or more, always written sequentially. B-trees break it into fixed-size blocks or pages, traditionally 4 KB, sometimes bigger, and read or write one page at a time. That design corresponds more closely to the underlying hardware, since disks are also arranged in fixed-size blocks.

root page100200300400500600interior page · 200 to 300200210230250270290leaf page · 250 to 270250251252253254255looking upkey 251pages are fixed-size, traditionally 4 KB, and every reference is an address on disk
The tree at restB-trees break the database into fixed-size blocks or pages, traditionally 4 KB, and read or write one page at a time. Each page has an address, which lets one page refer to another, like a pointer but on disk. One page is designated the root, and every lookup starts there.

Branching factor and depth

The number of references to child pages in one page is the branching factor. In practice it depends on the space needed to store page references and range boundaries, but typically it is several hundred. Which is the entire reason a lookup is three or four hops rather than thirty.

256 TB maximum storable, at these settings

The split algorithm keeps the tree balanced: a B-tree with n keys always has a depth of O(log n). Most databases fit into a B-tree three or four levels deep, so you do not need to follow many page references. The chapter’s benchmark case is the setting above, a four-level tree of 4 KB pages with a branching factor of 500, which stores up to 256 TB.

Updates, insertions, and the page split

To update an existing key, find the leaf page containing it, change the value in that page, and write the page back to disk. Any references to that page remain valid. To add a new key, find the page whose range encompasses it and add it there. If there is not enough free space, the page is split into two half-full pages, and the parent page is updated to account for the new subdivision of key ranges. Inserting is reasonably intuitive; deleting a key while keeping the tree balanced is somewhat more involved.

Making B-trees reliable

The basic underlying write operation of a B-tree is to overwrite a page on disk with new data, and it is assumed the overwrite does not change the page’s location, so all references to it remain intact. This is in stark contrast to LSM-trees, which only append to files and eventually delete obsolete ones, but never modify files in place. Think of it as an actual hardware operation. On a magnetic drive: move the head, wait for the right position on the spinning platter, overwrite the sector. On SSDs it is more complicated, because an SSD must erase and rewrite fairly large blocks of a storage chip at a time.

Why a page split is dangerous

insert 256 no room in the leaf
parent page · range boundaries
250253270
leaf page · one page becomes two half-full pages
250251252
253254255256
write-ahead log · appended before the tree is touched
page A page B parent

Crash after the two halves and before the parent, with no log, and page B is an orphan: a page that is not a child of any parent.

One insertion, three page writes, and an index that is corrupt in between.

Some operations require several pages to be overwritten. Split a page because an insertion overfilled it and you must write the two split pages and overwrite their parent to update the references. If the database crashes after only some of those writes land, you have a corrupted index, for example an orphan page that is not a child of any parent.

The remedy is an additional data structure on disk: a write-ahead log, also known as a redo log. It is an append-only file to which every B-tree modification must be written before it can be applied to the pages of the tree itself. After a crash, the log restores the B-tree to a consistent state.

Updating pages in place also demands careful concurrency control if multiple threads access the tree at once, or a thread may see the tree in an inconsistent state. This is typically done by protecting the data structures with latches, lightweight locks. Log-structured approaches are simpler here, because they do all the merging in the background without interfering with incoming queries, and atomically swap old segments for new ones from time to time.

B-tree optimizations, in brief

Copy-on-write instead of a WALSome databases, LMDB among them, use a copy-on-write scheme rather than overwriting pages and keeping a write-ahead log for crash recovery. A modified page is written to a different location, and a new version of the parent pages is created pointing at the new location. The same approach is useful for concurrency control.
The scoreboard § Comparing B-Trees and LSM-Trees

Rule of thumb, then all the caveats

As a rule of thumb, LSM-trees are typically faster for writes, whereas B-trees are thought to be faster for reads. Reads are slower on LSM-trees because they have to check several different data structures and SSTables at different stages of compaction. But B-tree implementations are generally more mature, and benchmarks are often inconclusive and sensitive to details of the workload, so you need to test with your particular workload to make a valid comparison.

Eight dimensions, and the chapter's reading of each. LSM-trees take 2, B-trees take 5, 1 is a draw

Write throughput · advantage LSM-treesLSM-trees are typically able to sustain higher write throughput, partly because they sometimes have lower write amplification, which depends on configuration and workload, and partly because they sequentially write compact SSTable files rather than having to overwrite several pages in the tree. The difference matters most on magnetic drives, where sequential writes are far faster than random ones.

Write amplification

A B-tree index must write every piece of data at least twice: once to the write-ahead log, and once to the tree page itself, and perhaps again as pages are split. There is also overhead from having to write an entire page at a time even if only a few bytes changed. Some storage engines even overwrite the same page twice, to avoid ending up with a partially updated page after a power failure. Log-structured indexes also rewrite data multiple times, through repeated compaction and merging of SSTables.

Write amplification is the name for this effect: one write to the database resulting in multiple writes to the disk over the course of the database’s lifetime. It is of particular concern on SSDs, which can only overwrite blocks a limited number of times before wearing out.

In write-heavy applications the bottleneck may be the rate at which the database can write to disk, and then write amplification carries a direct performance cost: the more a storage engine writes to disk, the fewer writes per second it can handle within the available disk bandwidth.

One nuance not to overstate: on many SSDs the firmware internally uses a log-structured algorithm to turn random writes into sequential writes on the underlying chips, so the impact of the storage engine’s own write pattern is less pronounced. Lower write amplification and reduced fragmentation are still advantageous, though, since representing data more compactly allows more read and write requests within the available I/O bandwidth.

The rest of the toolbox § Other Indexing Structures

Secondary indexes, and the ones that are not key-value at all

Everything so far has been key-value indexes, like a primary key index, which uniquely identifies one row in a relational table, one document in a document database, or one vertex in a graph database. Other records refer to it by that primary key, and the index resolves those references.

It is also very common to have secondary indexes: several per table via CREATE INDEX, often crucial for performing joins efficiently. A secondary index is easily constructed from a key-value index; the main difference is that keys are not unique, since many rows may share a key. Two ways to solve that: make each value in the index a list of matching row identifiers, like a postings list in a full-text index, or make each key unique by appending a row identifier to it. Either way, both B-trees and log-structured indexes work as secondary indexes.

Storing values within the index

Heap fileThe index value is a reference to the row, which lives elsewhere in a heap file, stored in no particular order. It may be append-only, or it may track deleted rows to overwrite them later. This is common because it avoids duplicating data when there are several secondary indexes: each index just references a location, and the data is kept in one place.

Updating a value without changing the key can be efficient, overwrite in place, provided the new value is not larger than the old. If it is larger it probably has to move somewhere with enough space, and then either all indexes must be updated to point at the new location, or a forwarding pointer is left behind in the old one.

As with any duplication of data, clustered and covering indexes speed up reads but require additional storage and add overhead on writes. Databases also need extra effort to enforce transactional guarantees, because applications should not see inconsistencies caused by the duplication.

Multi-column indexes

The most common kind is a concatenated index, which combines several fields into one key by appending one column to another, in the order the index definition specifies. This is an old-fashioned paper phone book: an index from (lastname, firstname) to phone number. Two of the three queries below are what it was built for. The third is the one that exposes it.

index on (lastname, firstname), sorted
Abbott, DianeChen, WeiChen, YunDupont, MarieKleppmann, MartinSmith, AdaSmith, JohnSmith, WeiZhang, Ada
WorksBecause of the sort order, all the Smiths are contiguous. One seek, then a sequential scan of the matching block.

Multi-dimensional indexes are a more general way to query several columns at once, which matters particularly for geospatial data. A restaurant-search site showing a map needs a two-dimensional range query:

SELECT * FROM restaurants WHERE latitude  > 51.4946 AND latitude  < 51.5079
                            AND longitude > -0.1162 AND longitude < -0.1004;

A standard B-tree or LSM-tree cannot answer that efficiently. It can give you all the restaurants in a range of latitudes at any longitude, or all in a range of longitudes anywhere between the poles, but not both simultaneously. One option is to translate the two-dimensional location into a single number with a space-filling curve and use a regular B-tree; more commonly, specialized spatial indexes such as R-trees are used. PostGIS implements geospatial indexes as R-trees using PostgreSQL’s Generalized Search Tree facility.

And the generalisation worth having: multi-dimensional indexes are not just for geography. A three-dimensional index on (red, green, blue) finds products in a range of colours. A two-dimensional index on (date, temperature) efficiently finds all 2013 observations where the temperature was between 25 and 30 degrees. With a one-dimensional index you would have to scan all of 2013 regardless of temperature and then filter, or the other way round; a 2D index narrows by both at once. This technique is used by HyperDex.

Full-text search and fuzzy indexes

Every index so far assumes exact data and lets you query exact values, or a range of values with a sort order. What they do not let you do is search for similar keys, such as misspelled words. Full-text search engines commonly expand a search for one word to include synonyms, ignore grammatical variations, search for words near each other in the same document, and other features that depend on linguistic analysis. To cope with typos, Lucene can search for words within a certain edit distance, where an edit distance of 1 means one letter has been added, removed or replaced.

A precise distinction worth holding onto. Lucene uses an SSTable-like structure for its term dictionary, needing a small in-memory index telling queries at which offset to look. In LevelDB that in-memory index is a sparse collection of some of the keys, exactly the exhibit above. In Lucene it is a finite state automaton over the characters in the keys, similar to a trie, and that automaton can be transformed into a Levenshtein automaton, which supports efficient search for words within a given edit distance. Other fuzzy techniques head toward document classification and machine learning.

Keeping everything in memory

Every data structure in this chapter so far is an answer to the limitations of disks. Compared to main memory, disks are awkward: data must be laid out carefully for good performance on both magnetic disks and SSDs. We tolerate that because disks have two significant advantages, they are durable and they have a lower cost per gigabyte than RAM. As RAM gets cheaper the cost argument erodes, and many datasets simply are not that big, so keeping them entirely in memory becomes feasible, potentially across several machines.

Caching only

Memcached is intended for caching use, where it is acceptable to lose data when a machine restarts.

Aiming for durability

Achieved with special hardware such as battery-powered RAM, by writing a log of changes to disk, by writing periodic snapshots, or by replicating the in-memory state to other machines. On restart, state is reloaded from disk or over the network from a replica.

It is still an in-memory database even when it writes to disk, because the disk is merely used as an append-only log for durability and reads are served entirely from memory. Writing to disk also has operational advantages: files can be backed up, inspected and analyzed by external utilities. VoltDB, MemSQL and Oracle TimesTen are in-memory databases with a relational model, whose vendors claim big improvements from removing all the overheads of managing on-disk structures. RAMCloud is an open source in-memory key-value store with durability, using a log-structured approach for the data in memory as well as on disk. Redis and Couchbase provide weak durability by writing to disk asynchronously.

The counterintuitive point, read this one twice

The performance advantage of in-memory databases is not that they avoid reading from disk. Even a disk-based storage engine may never need to read from disk if you have enough memory, because the operating system caches recently used disk blocks in memory anyway. They can be faster because they avoid the overheads of encoding in-memory data structures in a form that can be written to disk.

Besides performance, another interesting area is data models that are difficult to implement with disk-based indexes. Redis offers a database-like interface to priority queues and sets, and because it keeps everything in memory, the implementation is comparatively simple.

Recent research suggests the architecture could be extended past available memory without bringing back disk-centric overheads. The anti-caching approach evicts the least recently used data from memory to disk when memory runs short, and loads it back when accessed again. This is similar to what operating systems do with virtual memory and swap files, but the database can manage memory more efficiently than the OS because it works at the granularity of individual records rather than entire memory pages. It still requires the indexes to fit entirely in memory, like the Bitcask example at the start of the chapter. Further design changes will probably be needed if non-volatile memory technologies become widely adopted.

The other axis § Transaction Processing or Analytics?

Two access patterns, two kinds of bottleneck

In the early days of business data processing, a write to the database typically corresponded to a commercial transaction: making a sale, placing an order, paying a salary. Databases spread into areas with no money changing hands, but the term transaction stuck, now meaning a group of reads and writes that form a logical unit.

A transaction need not necessarily have ACID properties. Transaction processing just means allowing clients to make low-latency reads and writes, as opposed to batch processing jobs, which only run periodically, for example once per day.

The access pattern stayed similar even as the data changed: an application looks up a small number of records by some key, using an index, and inserts or updates records based on user input. Because these applications are interactive, the pattern became known as online transaction processing (OLTP).

Analytics has very different patterns. An analytic query usually scans over a huge number of records, reads only a few columns per record, and calculates aggregate statistics, count, sum, average, rather than returning raw data to the user. The chapter’s three examples:

  • What was the total revenue of each of our stores in January?
  • How many more bananas than usual did we sell during our latest promotion?
  • Which brand of baby food is most often purchased together with brand X diapers?

These are often written by business analysts and feed reports that help management make better decisions, which is business intelligence. To distinguish the pattern it was named online analytic processing (OLAP). The meaning of online there is unclear; it probably refers to the fact that queries are not just for predefined reports, but that analysts use the system interactively for explorative queries.

PropertyTransaction processing (OLTP)Analytic systems (OLAP)
Main read patternSmall number of records per query, fetched by keyAggregate over large number of records
Main write patternRandom-access, low-latency writes from user inputBulk import (ETL) or event stream
Primarily used byEnd user or customer, via a web applicationInternal analyst, for decision support
What data representsLatest state of data, the current point in timeHistory of events that happened over time
Dataset sizeGigabytes to terabytesTerabytes to petabytes

The distinction is not always clear-cut, and at first the same databases served both, since SQL turned out to be quite flexible and works well for either kind of query. Nevertheless in the late 1980s and early 1990s companies began to stop using OLTP systems for analytics and to run analytics on a separate database: the data warehouse.

Data warehousing

An enterprise may have dozens of transaction processing systems: the customer-facing website, point-of-sale checkout, warehouse inventory, vehicle route planning, supplier management, employee administration. Each is complex, needs a team, and ends up operating mostly autonomously. They are expected to be highly available and low-latency because they are critical to the business, so database administrators guard them closely and are usually reluctant to let analysts run ad hoc queries on them: those queries are expensive, scan large parts of the dataset, and can harm the performance of concurrently executing transactions.

A data warehouse is a separate database analysts can query to their hearts’ content without affecting OLTP operations. It holds a read-only copy of the data from all the various OLTP systems, brought over by Extract, Transform, Load (ETL): extracted using either a periodic data dump or a continuous stream of updates, transformed into an analysis-friendly schema, cleaned up, then loaded.

Why warehouses exist in large enterprises but are almost unheard of in small companies

Probably because most small companies do not have many different OLTP systems, and have a small enough amount of data that it can be queried in a conventional SQL database or even analyzed in a spreadsheet. In a large company, a lot of heavy lifting is required to do something that is simple in a small company. The big advantage of the separation is that the warehouse can be optimized for analytic access patterns, and the indexing algorithms from the first half of this chapter work well for OLTP but are not very good at answering analytic queries.

A warehouse’s data model is most commonly relational, because SQL is generally a good fit for analytic queries, and many graphical tools generate SQL, visualize results, and let analysts explore through operations like drill-down and slicing and dicing. On the surface a warehouse and a relational OLTP database look similar, both have a SQL interface, but the internals can look quite different because they are optimized for very different query patterns. Microsoft SQL Server and SAP HANA support both in one product, but they are increasingly becoming two separate storage and query engines that happen to be accessible through a common SQL interface.

Commercial warehouse vendors

Teradata, Vertica, SAP HANA, ParAccel, typically sold under expensive commercial licenses. Amazon RedShift is a hosted version of ParAccel.

SQL-on-Hadoop

A plethora of open source projects, young but aiming to compete with the commercial warehouses: Apache Hive, Spark SQL, Cloudera Impala, Facebook Presto, Apache Tajo, Apache Drill. Some are based on ideas from Google’s Dremel.

Stars and snowflakes

Transaction processing uses a wide range of data models depending on the application. Analytics has much less diversity: many warehouses are used in a fairly formulaic style known as a star schema, also known as dimensional modeling.

dim_date

date_keyyearweekdayis_holiday
1401022013Wedno

dim_product

product_skskubrandcategory
31OK4529Aunt JemimaBaking

dim_store

store_skstatecityhas_bakery
3WASeattleyes

fact_sales · one row per event

date_keyproduct_skstore_skpromotion_skcustomer_skquantitynet_pricediscount_price
140102313NULLNULL12.492.49
Star schemaAt the centre sits the fact table, and each row is an event that occurred at a particular time: here a customer's purchase of a product, for website traffic a page view or a click. Some columns are attributes, such as the price the product sold at and what it cost from the supplier, which together give the margin. The rest are foreign keys into dimension tables, and since each fact is an event, the dimensions are the who, what, where, when, how and why of it.

The name comes from the picture: the fact table in the middle, dimension tables around it, the connections radiating out like the rays of a star.

Facts are usually captured as individual events, because that allows maximum flexibility of analysis later, which is also why fact tables get extremely large. A big enterprise like Apple, Walmart or eBay may have tens of petabytes of transaction history in its warehouse, most of it in fact tables. Even date and time are often dimension tables, because that lets additional information about dates, such as public holidays, be encoded, so queries can differentiate between sales on holidays and non-holidays. Tables are often very wide: fact tables often have over 100 columns, sometimes several hundred, and dimension tables can be wide too, since dim_store might carry which services are offered at each store, whether it has an in-store bakery, the square footage, when it opened, when it was last remodeled and how far it is from the nearest highway.

The analytics answer § Column-Oriented Storage

Do not store rows together, store columns together

With trillions of rows and petabytes in your fact tables, storing and querying them efficiently becomes a challenging problem. Dimension tables are usually much smaller, millions of rows, so the focus is on facts. Although fact tables are often over 100 columns wide, a typical warehouse query only accesses 4 or 5 of them at one time. SELECT * queries are rarely needed for analytics.

-- Are people more inclined to buy fresh fruit or candy,
-- depending on the day of the week?
SELECT
  dim_date.weekday, dim_product.category,
  SUM(fact_sales.quantity) AS quantity_sold
FROM fact_sales
  JOIN dim_date    ON fact_sales.date_key    = dim_date.date_key
  JOIN dim_product ON fact_sales.product_sk = dim_product.product_sk
WHERE
  dim_date.year = 2013 AND
  dim_product.category IN ('Fresh fruit', 'Candy')
GROUP BY
  dim_date.weekday, dim_product.category;

It touches a large number of rows, every purchase of fruit or candy in 2013, but only three columns of fact_sales: date_key, product_sk and quantity. It ignores all the others. In most OLTP databases storage is laid out row-oriented, with all the values from one row stored next to each other, and document databases are similar, since an entire document is typically one contiguous sequence of bytes. Watch what that costs.

Columns loaded from disk and parsed · all 100

Row-orientedAll the values from one row are stored next to each other, so even with indexes on date_key and product_sk pointing at the right rows, the engine must still load every one of those 100 attributes from disk into memory, parse them, and filter out the rows that fail the conditions. The query needed 3 columns and read 100. That can take a long time.

The idea is simple: do not store all the values from one row together, store all the values from each column together instead. If each column is in a separate file, a query only needs to read and parse the columns it uses. The layout relies on each column file containing the rows in the same order, so to reassemble a row you take the 23rd entry from each column file and put them together to form the 23rd row. Column storage is easiest to understand in a relational data model, but it applies equally to nonrelational data: Parquet is a columnar format supporting a document data model, based on Google’s Dremel.

Column compression and bitmap encoding

Beyond loading fewer columns, you can further reduce demands on disk throughput by compressing, and column-oriented storage often lends itself very well to it, because the sequences of values in a column often look quite repetitive. One technique particularly effective in warehouses is bitmap encoding. Often the number of distinct values in a column is small compared with the number of rows: a retailer may have billions of sales transactions but only 100,000 distinct products.

column: product_sk · 20 rows, 8 distinct values
69 69 69 69 74 31 31 31 68 69 70 29 30 30 31 31 32 68 69 70
ReadyA single column of a fact table. Notice how repetitive it looks, which is the sign that pays for everything below.

A naming warning the chapter is explicit about

Cassandra and HBase have a concept of column families, inherited from Bigtable. It is very misleading to call them column-oriented: within each column family they store all columns from a row together, along with a row key, and they do not use column compression. The Bigtable model is still mostly row-oriented.

Memory bandwidth and vectorized processing

For queries scanning millions of rows, a big bottleneck is the bandwidth for getting data from disk into memory, but that is not the only one. Developers of analytical databases also worry about efficiently using the bandwidth from main memory into the CPU cache, about avoiding branch mispredictions and bubbles in the CPU instruction processing pipeline, and about making use of SIMD instructions in modern CPUs.

So column-oriented layouts are also good for making efficient use of CPU cycles. The query engine can take a chunk of compressed column data that fits comfortably in the CPU’s L1 cache and iterate through it in a tight loop with no function calls. A CPU can execute such a loop much faster than code requiring a lot of function calls and conditions per record. Column compression allows more rows from a column to fit in the same amount of L1 cache. And operators such as the bitwise AND and OR above can be designed to operate on those chunks of compressed data directly. This is vectorized processing.

Sort order in column storage

In a column store it does not necessarily matter in which order rows are stored. Insertion order is easiest, since then inserting a row just means appending to each of the column files. But you can choose to impose an order, as with SSTables, and use it as an indexing mechanism.

It would not make sense to sort each column independently, because you would no longer know which items in the columns belong to the same row. A row can only be reconstructed because the kth item in one column belongs to the same row as the kth item in another. So the data must be sorted an entire row at a time, even though it is stored by column.

Choose the sort keys and watch the runs form

columns, in storage order
date_keylongest run 2140101140102140103140102140103140102140103140102140103140101140103140103140102140103
product_sklongest run 23168686968696968693169683168
store_sklongest run 222113213312232
quantitylongest run 241423142123112
Insertion orderThe easiest choice, because inserting a row just means appending to each of the column files. No column has long runs, so run-length encoding buys almost nothing.

The administrator chooses the sort columns using knowledge of common queries. If queries often target date ranges such as the last month, it makes sense to make date_key the first sort key, so the optimizer can scan only the rows from the last month. A second column determines the order of rows tied on the first, so making product_sk second groups all sales for the same product on the same day together, which helps queries that group or filter sales by product within a date range.

Several different sort orders

A clever extension introduced in C-Store and adopted in Vertica: different queries benefit from different sort orders, so why not store the same data sorted in several different ways? Data needs to be replicated to multiple machines anyway so you do not lose it when one fails, so you might as well store that redundant data sorted differently, and use whichever version best fits the query pattern you are processing.

This resembles having multiple secondary indexes in a row-oriented store, with one big difference. A row-oriented store keeps every row in one place, in the heap file or a clustered index, and secondary indexes just contain pointers to the matching rows. In a column store there normally are no pointers to data elsewhere, only columns containing values.

Writing to column-oriented storage

All these optimizations make sense because most of the load in a warehouse is large read-only queries run by analysts. The downside is that they make writes more difficult. An update-in-place approach, as B-trees use, is not possible with compressed columns. Insert a row in the middle of a sorted table and you would most likely have to rewrite all the column files, because rows are identified by their position within a column, so the insertion has to update all columns consistently.

The solution is one we already have: LSM-trees. All writes first go to an in-memory store, where they are added to a sorted structure and prepared for writing to disk, and it does not matter whether that in-memory store is row-oriented or column-oriented. When enough writes accumulate, they are merged with the column files on disk and written to new files in bulk. This is essentially what Vertica does. Queries need to examine both the column data on disk and the recent writes in memory and combine the two, but the query optimizer hides this distinction from the user: from an analyst’s point of view, data modified by inserts, updates or deletes is immediately reflected in subsequent queries.

Aggregation, data cubes and materialized views

Not every data warehouse is necessarily a column store, since traditional row-oriented databases and a few other architectures are also used, but columnar storage can be significantly faster for ad hoc analytical queries, so it is rapidly gaining popularity. Warehouse queries often involve an aggregate function such as COUNT, SUM, AVG, MIN or MAX, and if the same aggregates are used by many queries, crunching the raw data every time is wasteful. So cache them.

Virtual view

A table-like object whose contents are the results of some query, but just a shortcut for writing queries. When you read from it, the SQL engine expands it into the underlying query on the fly and processes the expanded query.

Materialized view

An actual copy of the query results, written to disk. Because it is a denormalized copy, it must be updated when the underlying data changes. The database can do that automatically, but such updates make writes more expensive, which is why materialized views are not often used in OLTP databases. In read-heavy warehouses they make more sense, though whether they actually improve read performance depends on the individual case.

A common special case of a materialized view is a data cube or OLAP cube: a grid of aggregates grouped by different dimensions. Imagine each fact having foreign keys to just two dimension tables, date and product. Draw a two-dimensional table with dates on one axis and products on the other, and each cell holds the aggregate of an attribute of all facts with that date-product combination. Then apply the same aggregate along each row or column to get a summary reduced by one dimension.

BananasDiapersBaby foodtotal by date
2013-01-01149.6031.0184.58265.19
2013-01-02132.1859.0372.11263.32
2013-01-03100.0655.1280.44235.62
total by product381.84145.16237.13764.13
The cellsTwo dimensions, date and product. Each cell holds the aggregate, here SUM, of an attribute, here net_price, of all the facts with that date-product combination.

Facts often have more than two dimensions. The grocery example has five: date, product, store, promotion and customer. A five-dimensional hypercube is harder to picture but the principle is identical, each cell holding the sales for a particular date-product-store-promotion-customer combination, and those values can repeatedly be summarized along each dimension.

Recall check 12 questions

Did it stick?

One attempt per question; the explanation appears either way.

1. Why does the two-line Bash database have good write performance but terrible read performance?

2. What is the standing constraint on a Bitcask-style hash index?

3. What does compaction do, and why does it let you merge at the same time?

4. Requiring a segment file to be sorted by key gives SSTables three advantages. Which is NOT one of them?

5. In the LSM write path, what is the memtable and what happens when it exceeds its threshold?

6. How do size-tiered and leveled compaction differ, and which engines use which?

7. How do B-trees break up the database, compared with log-structured indexes?

8. Why do B-tree implementations commonly include a write-ahead log?

9. What exactly is write amplification?

10. Which is a genuine advantage of B-trees over LSM-trees, according to the chapter?

11. Why is the in-memory database advantage NOT simply that they avoid reading from disk?

12. Why is it wrong to sort each column independently in a column store?

Answered 0 of 12 · 0 correct

Vocabulary Click to flip

The terms this chapter installs


Chapter 3 in one breath. Storage engines fall into two broad categories. OLTP engines are user-facing and see a huge volume of requests, each touching a small number of records fetched by key through an index, and disk seek time is often the bottleneck. Analytic systems handle far fewer queries, but each is very demanding, scanning millions of records in a short time, and here disk bandwidth, not seek time, is often the bottleneck, with column-oriented storage as an increasingly popular answer.

On the OLTP side there are two schools of thought. The log-structured school only permits appending to files and deleting obsolete ones, never updating a file already written: Bitcask, SSTables, LSM-trees, LevelDB, Cassandra, HBase, Lucene. The update-in-place school treats the disk as a set of fixed-size pages that can be overwritten, with B-trees as the biggest example, used in all major relational databases and many nonrelational ones. Log-structured engines are a comparatively recent development, and their key idea is that they systematically turn random-access writes into sequential writes on disk, which enables higher write throughput given the performance characteristics of hard drives and SSDs.

The detour into warehouse architecture explains why analytic workloads are so different: when queries sequentially scan a large number of rows, indexes matter much less, and what becomes important is encoding data very compactly to minimize how much the query must read from disk.

Study aid built for recall practice. Concepts, code, figures and named systems are Martin Kleppmann’s, from Designing Data-Intensive Applications, Chapter 3, go back to the book for his wording and the full reference list. The chapter’s own closing caveat applies here too: this will not make you an expert in tuning any one storage engine, but it should give you enough vocabulary and ideas to make sense of the documentation for the database you actually use.

← all lab notes