Storage and Retrieval
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.
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.
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.
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.
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.
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
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
kitty00000andkitty99999, you would have to look up each key individually.
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.
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.
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.
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.
The write path
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
Used by HBase. Cassandra supports it.
Used by LevelDB, which is where the name comes from, and RocksDB. Cassandra supports it. This is also the case where LSM-trees have the clearest storage-overhead advantage over B-trees.
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.
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.
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
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.
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
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 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.
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
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.
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.4946AND latitude <51.5079AND longitude >-0.1162AND 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.
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.
| Property | Transaction processing (OLTP) | Analytic systems (OLAP) |
|---|---|---|
| Main read pattern | Small number of records per query, fetched by key | Aggregate over large number of records |
| Main write pattern | Random-access, low-latency writes from user input | Bulk import (ETL) or event stream |
| Primarily used by | End user or customer, via a web application | Internal analyst, for decision support |
| What data represents | Latest state of data, the current point in time | History of events that happened over time |
| Dataset size | Gigabytes to terabytes | Terabytes 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_key | year | weekday | is_holiday |
|---|---|---|---|
| 140102 | 2013 | Wed | no |
dim_product
| product_sk | sku | brand | category |
|---|---|---|---|
| 31 | OK4529 | Aunt Jemima | Baking |
dim_store
| store_sk | state | city | has_bakery |
|---|---|---|---|
| 3 | WA | Seattle | yes |
fact_sales · one row per event
| date_key | product_sk | store_sk | promotion_sk | customer_sk | quantity | net_price | discount_price |
|---|---|---|---|---|---|---|---|
| 140102 | 31 | 3 | NULL | NULL | 1 | 2.49 | 2.49 |
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.
fact_sales
| date_key | product_sk | store_sk | quantity | net_price |
|---|---|---|---|---|
| 140102 | 31 | 3 | 1 | 2.49 |
dim_product
| product_sk | sku | brand_sk | category_sk |
|---|---|---|---|
| 31 | OK4529 | 17 | 4 |
dim_brand
| brand_sk | brand_name |
|---|---|
| 17 | Aunt Jemima |
dim_category
| category_sk | category_name |
|---|---|
| 4 | Baking |
Snowflake schemas are more normalized than star schemas, but star schemas are often preferred, because they are simpler for analysts to work with.
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.
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 =2013AND 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
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.
| product_sk = 29 | 00000000000100000000 |
| product_sk = 30 | 00000000000011000000 |
| product_sk = 31 | 00000111000000110000 |
| product_sk = 32 | 00000000000000001000 |
| product_sk = 68 | 00000000100000000100 |
| product_sk = 69 | 11110000010000000010 |
| product_sk = 70 | 00000000001000000001 |
| product_sk = 74 | 00001000000000000000 |
It pays off because the number of distinct values is often small compared with the number of rows: a retailer may have billions of sales transactions but only 100,000 distinct products. If n is very small, say the roughly 200 values of a country column, the bitmaps can be stored at one bit per row and left at that.
| product_sk = 29 | 11, 1, 8 |
| product_sk = 30 | 12, 2, 6 |
| product_sk = 31 | 5, 3, 6, 2, 4 |
| product_sk = 32 | 16, 1, 3 |
| product_sk = 68 | 8, 1, 8, 1, 2 |
| product_sk = 69 | 0, 4, 5, 1, 8, 1, 1 |
| product_sk = 70 | 10, 1, 8, 1 |
| product_sk = 74 | 4, 1, 15 |
| product_sk = 30 | 00000000000011000000 |
| product_sk = 68 | 00000000100000000100 |
| product_sk = 69 | 11110000010000000010 |
| bitwise OR | 11110000110011000110 |
| product_sk = 31 | 00000111000000110000 |
| store_sk = 3 | 11010100110010110110 |
| bitwise AND | 00000100000000110000 |
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
| date_keylongest run 2 | 140101140102140103140102140103140102140103140102140103140101140103140103140102140103 |
| product_sklongest run 2 | 3168686968696968693169683168 |
| store_sklongest run 2 | 22113213312232 |
| quantitylongest run 2 | 41423142123112 |
| date_keylongest run 7 | 140101140101140102140102140102140102140102140103140103140103140103140103140103140103 |
| product_sklongest run 4 | 3131316868696968686868696969 |
| store_sklongest run 2 | 12323121223123 |
| quantitylongest run 2 | 24112214123431 |
The query payoff: if queries often target date ranges such as the last month, the optimizer can scan only the rows from the last month.
| date_keylongest run 4 | 140101140101140102140102140102140103140103140103140103140102140102140103140103140103 |
| product_sklongest run 6 | 3131316868686868686969696969 |
| store_sklongest run 2 | 12323122312123 |
| quantitylongest run 2 | 24112412321431 |
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.
| Bananas | Diapers | Baby food | total by date | |
|---|---|---|---|---|
| 2013-01-01 | 149.60 | 31.01 | 84.58 | 265.19 |
| 2013-01-02 | 132.18 | 59.03 | 72.11 | 263.32 |
| 2013-01-03 | 100.06 | 55.12 | 80.44 | 235.62 |
| total by product | 381.84 | 145.16 | 237.13 | 764.13 |
| Bananas | Diapers | Baby food | total by date | |
|---|---|---|---|---|
| 2013-01-01 | 149.60 | 31.01 | 84.58 | 265.19 |
| 2013-01-02 | 132.18 | 59.03 | 72.11 | 263.32 |
| 2013-01-03 | 100.06 | 55.12 | 80.44 | 235.62 |
| total by product | 381.84 | 145.16 | 237.13 | 764.13 |
| Bananas | Diapers | Baby food | total by date | |
|---|---|---|---|---|
| 2013-01-01 | 149.60 | 31.01 | 84.58 | 265.19 |
| 2013-01-02 | 132.18 | 59.03 | 72.11 | 263.32 |
| 2013-01-03 | 100.06 | 55.12 | 80.44 | 235.62 |
| total by product | 381.84 | 145.16 | 237.13 | 764.13 |
| Bananas | Diapers | Baby food | total by date | |
|---|---|---|---|---|
| 2013-01-01 | 149.60 | 31.01 | 84.58 | 265.19 |
| 2013-01-02 | 132.18 | 59.03 | 72.11 | 263.32 |
| 2013-01-03 | 100.06 | 55.12 | 80.44 | 235.62 |
| total by product | 381.84 | 145.16 | 237.13 | 764.13 |
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.
Did it stick?
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
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.