M3

Storage Layout: Row vs Columnar

A storage layout specifies how a table's bytes are stored on disk.

The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file

The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file. Both layouts live on disk, shown by green frames. The rating column is the highlight, shown in violet: a query for AVG(rating) reads only that one file in columnar, but every row page in row-oriented. Color key: green is the on-disk file layouts, violet is the focal rating column. what your query sees user_id song_id rating listen_time 42 100 5 10:32 7 200 4 10:35 13 100 3 10:40 42 300 5 10:42 7 100 5 10:45 stored as rows stored as columns Row-Oriented File · disk each page packs whole rows together Page 1 [ 42 · 100 · 5 · 10:32 ] [ 7 · 200 · 4 · 10:35 ] [ 13 · 100 · 3 · 10:40 ] Page 2 [ 42 · 300 · 5 · 10:42 ] [ 7 · 100 · 5 · 10:45 ] Columnar Files · disk each column gets its own file user_id 42 7 13 42 7 song_id 100 200 100 300 100 rating 5 4 3 5 5 AVG reads only this listen_time 10:32 10:35 10:40 10:42 10:45
The physical layout decides which bytes a query reads. The same logical Listens table appears in both layouts. Row-oriented storage packs whole rows into a page, so reading row 5 returns all four columns in one read and a write is one append. Columnar storage gives each column its own file, so AVG(rating) touches only the rating file and skips the other three. Which layout wins depends on the query: SELECT AVG(rating) favors columnar (row layout wastes about 75% of the I/O on this 4-column toy, far more on wide tables), while SELECT * WHERE listen_id = 42 and inserts or updates favor row, since one record is one packed page rather than a seek-and-reassemble across four files.
Notes ↗
M3

Case Study 3.1: BigQuery, From Web Crawl to Petabyte Analytics

BigQuery scales to trillions of rows by storing data in a columnar format and decoupling storage from compute.

Read 4-byte ratings, skip 5 MB songs

BigQuery: columnar storage scanned by thousands of on-demand workers on a tree Illustrative rows contain rating (4 bytes), genre (a 4-byte dictionary code), artwork (1 MB), and song audio (5 MB). The query reads rating and genre and skips both media payloads. Thin green bars mark on-disk columns. Workers scan the selected columns and aggregate results up a tree. artwork 1 MB skip rating 4 bytes read genre 4 bytes read song 5 MB skip Per row example worker worker worker worker worker × thousands,on demand Root: AVG(rating) per genre results bubble up the tree
Each row in this illustrative table has a 4-byte rating, a 4-byte genre dictionary code, 1 MB of artwork, and 5 MB of song audio. The query reads the two small columns and skips the media. Workers scan those columns in parallel and combine partial results. Thin green bars identify on-disk columns.
Notes ↗

The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file

The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file. Both layouts live on disk, shown by green frames. The rating column is the highlight, shown in violet: a query for AVG(rating) reads only that one file in columnar, but every row page in row-oriented. Color key: green is the on-disk file layouts, violet is the focal rating column. what your query sees user_id song_id rating listen_time 42 100 5 10:32 7 200 4 10:35 13 100 3 10:40 42 300 5 10:42 7 100 5 10:45 stored as rows stored as columns Row-Oriented File · disk each page packs whole rows together Page 1 [ 42 · 100 · 5 · 10:32 ] [ 7 · 200 · 4 · 10:35 ] [ 13 · 100 · 3 · 10:40 ] Page 2 [ 42 · 300 · 5 · 10:42 ] [ 7 · 100 · 5 · 10:45 ] Columnar Files · disk each column gets its own file user_id 42 7 13 42 7 song_id 100 200 100 300 100 rating 5 4 3 5 5 AVG reads only this listen_time 10:32 10:35 10:40 10:42 10:45
The physical layout decides which bytes a query reads. The same logical Listens table appears in both layouts. Row-oriented storage packs whole rows into a page, so reading row 5 returns all four columns in one read and a write is one append. Columnar storage gives each column its own file, so AVG(rating) touches only the rating file and skips the other three. Which layout wins depends on the query: SELECT AVG(rating) favors columnar (row layout wastes about 75% of the I/O on this 4-column toy, far more on wide tables), while SELECT * WHERE listen_id = 42 and inserts or updates favor row, since one record is one packed page rather than a seek-and-reassemble across four files.
Notes ↗

Slotted page layout: a single 64MB row-oriented page

Slotted page layout: a single 64MB row-oriented page Slotted page layout: a single 64MB row-oriented page on disk. Data records grow from the top down (Row A, B, C). The slot array grows from the bottom up; three slot cells (S0, S1, S2) point to their rows by label. Free space sits in the middle and shrinks as both grow toward each other. Color key: green is the page on disk (persistent); the rows and slots inside are neutral. on disk page header (metadata) Row A [42, 7, 100, 5, 10:32] Row B [7, 13, 200, 4, 10:35, 'longer comment field…'] Row C [13, 42, 300, 3, 10:40] data ↓ free space slot array (each slot = offset + length, fixed size) S0 → Row A S1 → Row B S2 → Row C + next → slots ↑
A 64 MB slotted page stores variable-length records from the top downward and a fixed-size slot array from the bottom upward. Free space remains between them. Each slot stores an (offset, length) pointer, so S0 points to Row A and S1 to Row B. An in-place update writes the row without moving it when the row keeps the same size. When a row grows, the DB moves the row to a new location inside the page and updates the slot, so references like page_id, slot_id remain stable.
Notes ↗

Parquet nests rows into row-groups, columns into column chunks, and bytes into ~1 MB pages, all in one file, for a sliver of compression in exchange for big gains in row access, file count, and write simplicity

Parquet nests rows into row-groups, columns into column chunks, and bytes into ~1 MB pages, all in one file, for a sliver of compression in exchange for big gains in row access, file count, and write simplicity Two layouts compared, revealed in steps. On the left, pure columnar (grey, out of focus) puts each column in its own file, so one row needs a seek per column and 1,000 columns require 1,000 files. On the right, one Parquet file (emerald, in focus) builds up in three beats: first the file as a sequence of row-groups of about 128 MB and a million rows, then the column chunks inside one row-group with the rating chunk highlighted, then a zoom showing a column chunk is a stack of roughly 1 MB pages. A note flags that Parquet's page is finer than the 64 MB DbFile page from earlier. A footer of per-row-group min/max statistics lets a query skip whole row-groups. The trade panel states the cost in amber and the gains in emerald. Color key: emerald is the Parquet file in focus, grey is pure columnar out of focus, amber is the cost. Pure columnar one file per column user_id.col song_id.col rating.col listen_time.col one row = a seek per column 1,000 columns = 1,000 files listens.parquet · one file on disk nesting: row group ▸ column chunk ▸ ~1 MB page Row Group 1 · ~128 MB (~1 million rows) Row Group 2 · ~128 MB ⋯ the file is a sequence of row-groups one chunk per column user_id 42 · 7 · 13 dictionary song_id 100 · 200 · 100 dictionary rating 5 · 4 · 3 run-length listen_time 10:32 · 10:35 delta Inside a column chunk: ~1 MB Parquet pages page · ~1 MB page · ~1 MB ⋯ ~1 MB Parquet page≠ 64 MB DbFile page Footer · schema + per-row-group min/max stats WHERE listen_time > T reads the footer first, then skips whole row-groups that cannot match. The trade Pay: compression resets at each row-group boundary, a sliver. Gain: a row stays local · 1 file, not 1,000 files · the row-group is the unit of work for writes and parallel scans.
Parquet stores columnar data inside row-groups in one file. A pure columnar layout stores one file per column, so reading one row performs a seek per column and 1,000 columns produce 1,000 files. Parquet keeps per-column compression and scan behavior. A row stays within one row-group, and row-groups support writes and parallel scans. The footer stores per-row-group min/max statistics, and predicates can skip groups without reading their column chunks. Encodings stop at row-group boundaries, which reduces compression slightly.
Notes ↗
M3

Hybrid Storage: One Table, Two Layouts

Hybrid storage keeps row-oriented and column-oriented layouts in the same table.

Hybrid storage: row on write, columnar on read, split by data age

Hybrid storage: row on write, columnar on read, split by data age A time axis runs from newest, hot data on the left to oldest, cold data on the right. On the left, fresh listens land as small grey row-shaped parts that take inserts at about a million per second. A grey background-merge arrow in the middle converts aged parts into the large green columnar segments on the right, which answer analytics scans in about 100 ms with roughly ten to one compression. A neutral unified-query panel spans the bottom: one SQL surface reads both layouts and merges the results. Color key: green is the columnar storage; grey is the row parts and the scaffolding. newest · hot (writes) oldest · cold (scans) Recent partitions row-shaped part row-shaped part row-shaped part Inserts: ~1M/sec whole row written in one place background merge when activity slows row ~10:1 Aged partitions columnar segment columnar segment Scans: ~100 ms · ~10:1 compression one column read, the rest skipped Unified query engine: one SQL surface reads both layouts and merges the result
listen_time provides a partitioning dimension. The system writes fresh listens into small row-shaped parts (grey) to accept inserts at about 1M/sec by writing each row contiguously. A background merge rewrites older parts into columnar segments (green) after activity slows. Analytics scan one column and skip others at roughly 10:1 compression. One query engine reads both layouts through the same SQL surface.
Notes ↗
M3

Hash Partitioning: Divide and Conquer

Hash partitioning splits a too-big-for-RAM table into N partitions by hashing a key.

Hash partitioning through a fixed RAM buffer

Hash partitioning through a fixed RAM buffer Hash partitioning shown as DISK input on the left, a small fixed RAM buffer in the middle, and DISK partitions on the right. The disk columns are visibly large; RAM is intentionally small to convey that the input file does not fit in RAM. The source file is read one page at a time into a flat one-page input buffer on the left of RAM. A hash function routes each row to output buffers B0, B1, and B2 on the right. Buffers show all six complete rows after processing input pages P1 through P3, before any flush. Each output buffer points horizontally to its final disk partition, shown after the full scan. When a buffer fills, it flushes to its partition file on disk. Color key: red is RAM (volatile and limited), green is disk (persistent), grey is out of focus. DISK · input P1 42·100, 7·200 P2 13·100, 42·300 P3 7·100, 13·200 P4 42·400, 99·500 P5 88·600, 7·700 P6 13·800, 42·900 ⋮ + thousands more pages (won't fit in RAM) RAM Input buffer · one page P3 7·100, 13·200 read h(user_id) % 3 Buffers after P3 B0 42·100, 42·300 B1 7·200, 7·100 B2 13·100, 13·200 Hash the key; keep the whole row DISK · final partitions flush P0 42·100, 42·300 42·400, 99·500 42·900, … flush P1 7·200, 7·100 88·600, 7·700 … flush P2 13·100, 13·200 13·800, …
Hash partitioning makes one pass over a too-big-for-RAM file using a small, fixed RAM buffer: one input slot plus one output buffer per partition. A page streams into the input slot, each row hashes by h(user_id) % B and routes to one of ~B output buffers. The instant any buffer fills, it flushes to its partition file on disk and is cleared, so RAM never holds more than that fixed set of buffers no matter how large the input. Partitions are stored on disk, not in memory: the full partitions live on disk, while only the small fixed RAM buffer is resident at any point in time. Rows with the same key always land in the same partition, so later stages can sort, join, or group one partition at a time. See the IO Cost Summary for the cost breakdown.
Notes ↗
M3

BigSort: Sorting TBs of Data with GBs of RAM

BigSort (or external merge-sort) sorts a table larger than RAM by reading RAM-sized chunks, sorting each chunk in memory, writing each sorted chunk to disk, then merging the sorted chunks.

External merge sort as a memory model

External merge sort as a memory model Pages stream from disk through a small four-page RAM box and back. Phase one reads four pages into RAM, quicksorts them, and writes a sorted run; repeat to get several runs. Phase two reads the head page of each run into RAM and emits the smallest value over and over, merging the runs into one sorted file. Each tile is a page shaded by its sort key, light for small and dark for large, so a sorted run reads as a smooth light-to-dark gradient. Color key: the green box is disk (persistent), the red box is RAM (volatile), the neutral tiles are pages shaded by sort key. PHASE 1 · SORT RUNS Disk · unsorted file read 4RAM · 4 pages quicksort in place write runDisk · sorted runs (each a gradient) PHASE 2 · MERGE Disk · sorted runsread each headRAM · merge buffers in buffersout · emit smallest writeDisk · one fully sorted run
Phase 1 reads four pages into memory, quicksorts them in place, and writes the pages back as one sorted run, repeating until the file becomes a set of sorted runs. Phase 2 keeps the head page of each run in memory, repeatedly selects the smallest remaining value, and writes it to the output to merge the runs into one sorted file. Each tile is a page shaded by its sort key, light for small and dark for large.
Notes ↗

BigSort: move every page yourself

Notes ↗

The official CS145 IO equations

The official CS145 IO equations
Notes ↗
M3

Block Nested Loop Join: The Simple Double Loop

Block nested loop join loads a block of the outer table into RAM, scans the inner table once per block, and applies the join predicate to candidate row pairs.

Block Nested Loop Join: disk inputs, RAM buffers, comparison, and joined output

Block Nested Loop Join: disk inputs, RAM buffers, comparison, and joined output In this example R fits in RAM. Load R once into a retained block. Stream S one page at a time into a one-page input buffer. Compare every row in that page with the R block, then emit matching joined rows. Repeat for all four S pages to produce eight rows. Green marks persistent disk; red marks RAM. DISK · inputs R · 1 page P1 4 rows S · 4 pages P1 42·100, 7·200 P2 13·100, 42·300 P3 7·100, 13·200 P4 42·400, 99·500 RAM R · one page stays in RAM P1 7·Mickey, 13·Minnie, 42·Pluto, 99·Daffy load once S input buffer · one page P1 42·100, 7·200 stream one page at a time Compare each S row with every R row Replace the S page; keep the R block emit matches Join output user_id · name · song_id 42 · Pluto · 100 7 · Mickey · 200 13 · Minnie · 100 42 · Pluto · 300 7 · Mickey · 100 13 · Minnie · 200 42 · Pluto · 400 99 · Daffy · 500 8 rows across all 4 S pages
Block Nested Loop Join, R ⋈ S. Read the smaller table R from disk and hold it in RAM for the whole join. The larger table S streams past in 4 pages, each loaded into a one-page slot, probed against R, then discarded, producing 8 joined rows (2 per S page here). Best case, when R fits in B pages, is P(R) + P(S) + OUT; if R is bigger than B it becomes P(R) + ⌈P(R)/B⌉ × P(S) + OUT, because S is re-scanned once per R-block. Always make the smaller table the outer one.
Notes ↗
M3

Hash Partition Join

Hash partition join joins two tables that do not fit in RAM.

Hash Partition Join: in Phase 1 both R and S are hashed on the join key into matching partitions on disk; in Phase 2 each partition pair is joined in RAM

Hash Partition Join: in Phase 1 both R and S are hashed on the join key into matching partitions on disk; in Phase 2 each partition pair is joined in RAM Hash Partition Join. Phase 1: both R and S are read from disk and hashed on the join key into matching partitions written back to disk, so the same key always lands in the same partition number. Phase 2: each partition pair is loaded into RAM and joined there. Color key: green is the tables and partitions on disk (persistent), red is the per-partition join in RAM (volatile); partitions are identified by their h to 0, 1, 2 labels. Phase 1 · read from disk, hash into partitions on disk R (Users) · disk user_id name 7 Mickey 13 Minnie 42 Pluto 99 Daffy h(user_id) R₀ · disk (h → 0) 42 · Pluto 99 · Daffy R₁ · disk (h → 1) 7 · Mickey R₂ · disk (h → 2) 13 · Minnie S (Listens) · disk user_id song_id 42 100 7 200 13 100 42 300 7 100 13 200 42 400 99 500 h(user_id) (same h) S₀ · disk (h → 0) 42 · 100 42 · 300 42 · 400 99 · 500 S₁ · disk (h → 1) 7 · 200 7 · 100 S₂ · disk (h → 2) 13 · 100 13 · 200 Phase 2 · load each partition pair into RAM and join there R₀ ⋈ S₀ · in RAM build hash on R₀, probe with S₀ → 4 output rows user_id 42 → 3 matches · user_id 99 → 1 match R₁ ⋈ S₁ · in RAM build hash on R₁, probe with S₁ → 2 output rows user_id 7 → 2 matches R₂ ⋈ S₂ · in RAM build hash on R₂, probe with S₂ → 2 output rows user_id 13 → 2 matches
Hash Partition Join, R ⋈ S on user_id. Phase 1 hashes both tables with the same function so matching user_ids always land in the same partition number. Phase 2 joins each (Rᵢ, Sᵢ) pair independently in RAM, building a hash table on R and probing with S, three parallelizable joins that yield 4 + 2 + 2 = 8 rows. Best-case cost is 3(N+M) + OUT (read each table, write partitions, read them back, write the output). The worst case is hash skew, where a hot key overflows a partition past RAM and recursive re-partitioning or a sort-merge fallback adds another pass.
Notes ↗
M3

Sort-Merge Join: When Order Matters

Sort-merge join sorts both tables on the join key, then scans them in lockstep and emits matching rows for equal keys.

Sort-Merge Join: after Phase 1 BigSort, both R and S are sorted on disk on the join key

Sort-Merge Join: after Phase 1 BigSort, both R and S are sorted on disk on the join key Sort-Merge Join: after Phase 1 BigSort, both R and S are sorted on the join key and live on disk. Phase 2 walks both tables in one pass with two pointers in RAM, emitting matched groups as it goes. The dashed match lines connect each key's R row to its matching S rows: key 7 yields 2 outputs, key 13 yields 2, key 42 yields 3, key 99 yields 1. Total 8 output rows. Color key: green is the sorted tables on disk (persistent), red is the two-pointer merge in RAM (volatile). R sorted on user_id · disk user_id name 7 Mickey 13 Minnie 42 Pluto 99 Daffy S sorted on user_id · disk user_id song_id 7 100 7 200 13 100 13 200 42 100 42 300 42 400 99 500 match match match match i ▸ j ▸ Two-pointer merge in RAM advance the smaller pointer, emit on equal keys 8 output rows streamed to disk
Sort-Merge Join, R ⋈ S on user_id (Phase 2 shown). Phase 1 (not shown) BigSorts both R and S on the join key (see the big sort page). Phase 2 scans the two sorted runs with two pointers. Equal keys form a matching key group, and the operator emits every pair in the group. Unequal keys advance the smaller side. The example advances through user_id 7 → 2, 13 → 2, 42 → 3, 99 → 1, total 8 rows. A key that appears on only one side emits nothing under inner join semantics. A key with m matches on one side and n on the other emits the m × n cross-product for that key. The output stream stays sorted on the join key, so a downstream operator that requires that order does not add a sort. Best case is P(R) + P(S) + OUT for one linear scan per run, plus the two Phase-1 BigSort costs. Worst case is O(P(R) × P(S)) + OUT when one key dominates and the merge performs a per-key cross-product. OUT can dominate when match rates are high.
Notes ↗
M3

IO Algorithms: Cost Analysis & Intuition

The IO cost of every algorithm in this module (BigSort, HashPartition, BNLJ, SMJ, HPJ) is a function of table size, buffer size, and the read/write cost ratio.

The official CS145 IO equations

The official CS145 IO equations
Notes ↗

Join IO cost versus buffer size, log scale

Join IO cost versus buffer size, log scale A log-scale line chart of IO cost against buffer size B for the same join, Songs3 100 GB by Listens3 1 TB, at C_r = C_w = 1. Buffer grows left to right: 10, 100, 1000, 10000 pages. BNLJ, the solid ink line, starts highest at 2.6M IOs and drops steeply to 18K as the buffer grows. SMJ-best, the dashed line, falls gently from 159K to 54K. HPJ-best, the dotted line, is flat at about 54K. A green crossover divider marks where the cheapest algorithm switches: HPJ is cheapest at small buffers, BNLJ at large buffers. Numbers are verified from the Module 3 NanoDB colab. Color key: green is the crossover and the winning regions; ink and grey are the cost lines and axes. 10K 100K 1M IO cost (log) B=10 B=100 B=1,000 B=10,000 buffer size B (pages) HPJ cheapest BNLJ cheapest BNLJ 2.6M BNLJ 18K SMJ-best 54K HPJ-best 54K (flat)
HPJ-best stays near 54K because partitioning does one read and one write independent of B. BNLJ drops from 2.6M to 18K because larger B reduces rescans of the inner table. SMJ-best drops as larger initial runs reduce merge passes. The crossover marks the point where BNLJ becomes cheaper than HPJ at this scale. SMJ with many duplicates can rescan during merge. HPJ with heavy skew can create oversized partitions, each up to 2 to 10x worse.
Notes ↗
M3

Index Fundamentals

An index maps a search key, such as artist, to the data pages containing matching rows.

Index finds topics quickly

Index finds topics quicklyA Python programming book has a Quicksort section on page 201 in its sorting chapter. The separate alphabetical index lists Binary search, Functions, Loops, Quicksort, and Variables. Quicksort points to pages 201 and 208; the arrow leads to page 201. The chapters remain in teaching order.CHAPTER 5 · SORTING ALGORITHMSQuicksortPartition around a pivot.Sort the smaller parts recursively.…201ALPHABETICAL INDEX · BACK OF BOOKBinary search184, 190Functions68, 75Loops35, 48Quicksort201, 208Variables8, 15Look up “Quicksort” → turn to pages 201 and 208 in the book.Python programming book.
A Python programming book organizes its chapters for teaching, not alphabetically by topic. Its back-of-book index sorts topics: “Quicksort” points to pages 201 and 208.
Notes ↗

Sorted pages need only a range index

Sorted pages need only a range indexA dictionary page 128 contains dragon, draw, and dream. A small range index maps door–dozen to page 127, dragon–dream to page 128, and dress–dry to page 129. Since the words are sorted, the index need not list every word. This is an Oxford English Dictionary analogy, not a reproduction of its index.DICTIONARY PAGE · ALPHABETICAL ORDERdragondrawdream128RANGE INDEXWord rangePagedoor–dozen127dragon–dream128dress–dry129“draw” falls in dragon–dream → read page 128, then find the word.Oxford English Dictionary analogy.
Think of the Oxford English Dictionary: its words are already alphabetical. An index can store each page’s word range rather than every word. To find “draw,” choose the dragon to dream range, then search page 128.
Notes ↗

Index keys and pointers, not whole rows

Index keys and pointers, not whole rows Table size vs index size: on the left the Songs table is a 10 TB file of 160,000 pages storing whole rows. On the right the artist index is a 20 MB file of one page storing only (artist, page#) pointers. Size bars beneath show 10 TB vs 20 MB at the same horizontal scale. Both the table and the index live on disk and are shown green; the size difference is carried by the bar widths, not by colour. Color key: green is on-disk storage; the 10 TB table bar versus the 20 MB index sliver shows the roughly 500,000x size difference. rows on disk vs. (key, page#) pointers Songs table 10 TB · 160,000 pages 1 · Hey Jude · Beatles · 1968 2 · Bohemian Rhapsody · Queen · 1975 3 · Shake It Off · Taylor Swift · 2014 … 999,997 more rows (~10 MB each) holds the rows themselves 500,000× smaller rows → pointers Artist index 20 MB · 1 page Beatles → Page 47 Queen → Page 23 Taylor Swift → Page 12 … 999,997 more entries (~20 B each) holds (key, page#) pointers relative size on disk (same horizontal scale) 10 TB 20 MB (barely visible at this scale)
The Songs table stores 10 MB rows (10 TB, 160,000 pages). The artist index stores 20-byte pointers (20 MB, one page), 500,000 times smaller. WHERE artist = 'Beatles' without an index scans 160,000 pages. With an index, the database reads one index page, learns Beatles is on page 47, then reads page 47. The query uses two IOs instead of 160,000, 80,000 times fewer for the same answer.
Notes ↗
M3

Hash Indexes

A hash index computes hash(key) and uses it to choose the page that stores the key's index entry.

hash(key) → index page → entry → data pages

hash(key) → index page → entry → data pages A lookup for Dan computes hash(Dan) = 1 in this example, selecting index page 1. The Dan entry points to data pages 0, 1, and 4, which hold the matching rows. The violet path follows this lookup; green marks index and data pages on disk. Data pages hold the actual rows Page 0 Bob, Carol, Dan,Eve, Frank Page 1 Dan, Grace, Henry,Ian, Jack Page 2 Alice, Lisa, Mike,Nancy, Oscar Page 3 Paul, Quinn, Rose,Steve, Tom Page 4 Dan, Uma, Victor,Wendy, Xavier Hash index pages hold (value, page#) pointers Index page 0 Alice → Page 2 Bob → Page 0 Carol → Page 0 Index page 1 Dan → Pages 0, 1, 4 Eve → Page 0 Frank → Page 0 Index page 2 Grace → Page 1 Henry → Page 1 Ian → Page 1 … more index pages Look up Dan hash(Dan) = 1
A hash index on People.name stores (search_key, page#) entries in buckets chosen by hash(value). Here, hash(Dan) = 1 selects index page 1. Its Dan entry points to data pages 0, 1, and 4. The lookup requires 4 IOs: one index-page read and three data-page reads.
Notes ↗
M3

B+Tree Indexes

A B+Tree index stores keys in sorted order in a balanced tree.

Build a hierarchy over sorted leaves

Build a hierarchy over sorted leaves B+Tree layout on Songs.catalog_no. Data pages on the left hold the rows. The tree on the right has a root node, three internal nodes, and a row of leaf nodes. Leaves store (key, data-page pointer) pairs in sorted order; neighbouring leaves are linked left-to-right so a range scan can walk across them without returning to the root. Color key: green is the index and data pages on disk (persistent), violet is the lookup trace for key 12890. Song ID · Title · Catalog ID Page 0 1 Evermore 4821 2 Willow 4953 3 Shape of You 3187 Page 1 4 Photograph 3245 5 Shivers 3367 6 Yesterday 1523 Page 2 7 Yellow Submarine 1587 8 Hey Jude 1612 9 Bad Blood 1899 Page 3 10 Flowers 11234 11 Unholy 11567 12 As It Was 12890 13 Heat Waves 13456 Page 4 15 Blinding Lights 13980 16 Good 4 U 14456 17 Drivers License 14789 Index pages 1 · Leaves Leaf · < 1900 1523 → Pg 1 1587 → Pg 2 1612 → Pg 2 Leaf · 3000–3999 3187 → Pg 0 3245 → Pg 1 3367 → Pg 1 Leaf … Leaf · 11234–13788 11234 → Pg 3 12890 → Pg 3 13456 → Pg 3 Leaf · 13789–14455 13789 → Pg 3 13980 → Pg 4 14001 → Pg 4 Leaf · ≥ 14456 14456 → Pg 4 14789 → Pg 4 ← leaves linked for sequential scans → 2 · Routing hierarchy Internal 1900 3000 4000 Internal 11234 13789 14456 Internal 17000 20000 22000 Root 5000 15000 25000 < 5000 5000–14999 15000–24999
Data pages on disk store the Songs rows. The B+Tree on disk stores a root and internal nodes that route lookups to sorted leaves. Leaves store (key, data-page pointer) pairs and link left-to-right. For catalog_no = 12890 ("As It Was"), the root selects 5000..14999, the internal node selects 11234..13788, the leaf resolves 12890 → Page 3, and Page 3 contains the row. With no pages cached, the lookup reads three index pages and one data page: four IOs.
Notes ↗

Find catalog numbers: 12,000 ≤ key ≤ 14,000

Find catalog numbers: 12,000 ≤ key ≤ 14,000 Walking a range query SELECT * WHERE catalog_no BETWEEN 12000 AND 14000 in a B+Tree. Step 1 visits the root. Step 2 descends to the middle internal node (the other two internals are skipped). Step 3 walks from leaf 3 to leaf 4 sequentially, loading data pages 3 and 4. Stop at 14001 in leaf 4. Total 6 IOs versus 160,000 for a full scan. Color key: green is the index and data pages on disk (persistent), violet arrows show the lookup path, grey dashed is skipped or not visited. Data pages Index pages Page 0 Page 1 Page 2 Page 3 Page 4 Root · step 1 5000 15000 25000 12000 ≥ 5000, < 15000 Internal (< 5000) skipped Internal · step 2 11234 13789 14456 Internal (15000–24999) skipped Leaf (< 11234) not visited Leaf 3 · step 3 11234 → Pg 3 12890 → Pg 3 13456 → Pg 3 ≥ 12000 ✓ Leaf 4 · step 3 (cont.) 13789 → Pg 3 13980 → Pg 4 14001 → Pg 4 14001 > 14000: stop Page 312890, 13456, 13789matching catalog numbers Page 413980matching catalog number Cost: 4 index-page reads + 2 data-page reads = 6 IOs Index: root, internal, leaves 3 and 4 · Data: pages 3 and 4 Full scan: 160,000 IOs · about 27,000× fewer IOs with the index
For 12000 ≤ catalog_no ≤ 14000, matching rows occupy data pages 3 and 4. The descent runs root → middle internal → Leaf 3, then follows leaf links. Leaf 3 yields 12890 and 13456. Leaf 4 yields 13789 and 13980. The next key in Leaf 4 is 14001, which exceeds 14000, so the scan stops without reading another leaf. The query reads 4 index pages and 2 data pages, 6 IOs total, versus roughly 160,000 for a full scan, about 27,000× fewer IOs.
Notes ↗

High fanout keeps B+Trees shallow

Fanout is the number of children per internal page. Higher fanout means fewer levels.

$$ f \approx \frac{\text{page size}}{\text{key size} + \text{pointer size}}, \qquad h \approx \left\lceil \log_f N \right\rceil $$

N = number of indexable keys; h = approximate number of index levels.

Notes ↗

B+Trees: few reads, more work on writes

For 1 trillion keys and 8-byte pointers, ignoring headers and unused space:

Page size Key size Approx. fanout Index levels
64 KB 8 B 4,096 4
64 KB 1 KB 63 7
64 MB 8 B 4.2 million 2
64 MB 1 KB 65,027 3
  • Reads: High fanout keeps the tree shallow. Point queries follow one path; range queries continue through sorted leaves.

  • Writes: Inserts update pages. Full pages split, and splits can propagate upward. Maintaining balance adds work under heavy writes.

Notes ↗
M3

SSTables: Sorted String Tables

An SSTable is an immutable file of key-value pairs sorted by key.

An SSTable is a sorted, immutable file

An SSTable is a sorted, immutable file One immutable SSTable contains a Bloom filter, a sparse index, and sorted data blocks. In the dictionary analogy, a Bloom filter first checks whether a word may exist; an absent result skips the file. The sparse index maps the first word of each sorted page to that page: door to page 201, dragon to page 202, and dress to page 203. To find draw, choose the dragon entry and read page 202. Page numbers illustrate block offsets; the dictionary example is schematic. Multiple SSTable files can coexist on disk. 1 · one SSTable file, three sections Bloom filter held in RAM fast not-exists check Index block on disk sparse (every Nth key) key100 → 4096 key200 → 8192 key300 → 12288 Data blocks · sorted by key on disk · compressed · read by offset key100 · v1 · {…} key101 · v1 · {…} key150 · v1 · {…} key200 · v1 · {…} Analogy: Oxford English Dictionary Bloom filter held in RAM Does “draw” exist? Does “diagon alley” exist? Index block first word → page door → Page 201 dragon → Page 202 dress → Page 203 One entry per page. For “draw,” choose the “dragon” entry. Data pages · sorted strings word → definition Page 201 door an entrance dove a bird dozen twelve Page 202 dragon a mythical creature draw make a picture dream images during sleep Page 203 dress a garment drip fall in drops dry not wet Each SSTable has its own sparse index and sorted data; multiple files can coexist.
An SSTable is one immutable on-disk file with three sections: a Bloom filter cached in RAM for fast not-exists checks, a sparse index block that binary-searches to the target data block, and sorted, compressed data blocks. Think of an Oxford English Dictionary: the sparse index records the first word on each page, while the pages contain sorted words and their definitions. To find draw, follow the dragon → Page 202 entry. These illustrative page numbers stand for data-block offsets in an SSTable. Multiple SSTables can coexist, each with its own index and sorted data.
Notes ↗
M3

LSM Tree Indexes

An LSM tree buffers inserts in a RAM MemTable, flushes them to immutable SSTables, and merges SSTables in the background.

Buffer writes, flush files, then compact

Buffer writes, flush files, then compact LSM write path at 1:44 PM. Top lane: incoming plays land in the live MemTable in RAM. Middle lane: every 15 minutes the MemTable is flushed as a Level-0 SSTable on disk; the 1:01, 1:15, and 1:30 files are already there. Bottom lane: every hour compaction merges Level-0 files into one larger Level-1+ SSTable. Song S75 has +2 plays in the MemTable, +18 across the Level-0 flushes, and 5420 in the compacted history. Color key: red strip is the MemTable in RAM (volatile), green strip is SSTables on disk (persistent). LiveRAMMemTable · 1:44 PMSongNum. playsS121S442S752S991S1021S2015Incoming plays Flush every 15 min Flush Level-0 · disk 1:30pm.sst … S55:1 S75:10 S88:2 … 1:15pm.sst … S12:5 S102:5 S111:1 … 1:01pm.sst … S22:4 S75:8 S99:2 … Compact hourly Compact before1:00pm.sst … S50: 110 S62: 45 S75: 5420 S88: 12 …
LSM write path at 1:44 PM, RAM on top and disk below, time flowing downward. Incoming plays land in the in-RAM MemTable (fast, no seek, lost on crash unless replayed from the WAL). Every 15 minutes the engine flushes it to an immutable Level-0 SSTable on disk (1:01pm.sst, 1:15pm.sst, 1:30pm.sst). Hourly compaction merges those into one larger sorted, de-duplicated Level-1+ file. Total plays for S75 spread across tiers (+2 in the MemTable, +10, no matching entry, and +8 in Level-0, 5,420 in compacted history) and the read path sums them to 5,440 (merge-on-read). The merge operator is configurable: "add" for counters, "newest wins" for last-write-wins keys, "set union" for tags, "max" for high-water-marks. The merge section walks through more examples.
Notes ↗

Combine recent writes with stored history

Combine recent writes with stored historyRead S75 from the MemTable and SSTables. MemTable contributes 2; 1:30pm.sst contributes 10; 1:15pm.sst has no matching entry; 1:01pm.sst contributes 8; before1:00pm.sst contributes 5420. Sum: 5440. Red and green top rules indicate RAM and disk. QueryTotal plays for S75?At 1:44 PMMemTable · RAMSongNum. playsS75+2Level-0 · disk1:30pm.sstS75: +101:15pm.sstNo S75 entry1:01pm.sstS75: +8before1:00pm.sst…S50: 110S62: 45S75: 5420S88: 12…2 + 10 + 0 + 8 + 5420 = 5,440 plays
Merge-on-read computes S75's play count at read time instead of storing it, by summing every tier that holds a fragment. The live in-RAM MemTable (red) adds +2, the three flushed Level-0 SSTables on disk (green) add 10 + 0 + 8 = 18, and the compacted Level-1+ SSTable on disk (green) adds +5,420, for a total of 2 + 18 + 5,420 = 5,440 (the merge function is "add" because plays are a counter). Each SSTable carries a bloom filter, so the reader skips any tier that cannot hold the key with no disk read, making most reads far cheaper than this worst case.
Notes ↗
M3

Case Study 3.3: How LSM trees handle Spotify-scale activity

This example uses an LSM tree to batch writes for play counters, playback positions, and friend activity.

Three workloads share the same write path

Three workloads share the same write path Illustrative Spotify-style architecture. Play increments, timestamped playback positions, and listening events enter a compact RAM MemTable. Flushes produce sorted SSTables, and compaction merges them using the workload's rule. Thin red bars mark RAM and green bars mark disk. The next slide contrasts sum, newest timestamp, and merging events in time order. Activity writesPlay → counter +1Pause → save positionListen → append eventMemTable · RAMKeyValuecount:S75+1position:U1208:42 @ 13:44activity:U9S42 @ 13:44FlushSSTables · disk1:30pm.sstSorted records…1:15pm.sstSorted records…1:01pm.sstSorted records…Compactcompacted.sstSorted records……
In this example, a play writes a counter increment, a pause writes a timestamped position, and a listen appends an activity event. The shared write path buffers records in RAM, flushes sorted SSTables, and compacts them using the appropriate merge rule. The key prefixes distinguish the three example workloads.
Notes ↗

Three LSM use cases in Spotify

Use case What gets written What the read does
1. Counters Each play adds +1 Sum the counts
2. Latest position wins Playback position and timestamp Return the newest position
3. Friend activity Timestamped listening events Merge events in time order
Notes ↗

Map each word to its matching songs

Map each word to its matching songsSearch for songs containing both Crazy and Heart. Two sorted term-index leaf pages contain multiple term rows. Crazy and Heart point to sorted postings lists. Intersect the song IDs in RAM to obtain 12 and 108, then fetch their song records from data pages. Routing nodes above the term pages are omitted. Data pages are schematic. Find songs containing “Crazy” AND “Heart”Term index pagesCrazyDanceDreamHeartHelloLoveMusicYesterdaySorted terms; one row per termPostings lists · song IDsCrazy12, 45, 108, 145, 201, …Heart8, 12, 88, 108, 180, …Intersect the lists in RAMMatching song IDs: 12, 108Song data pagesSong 12Title, artist, …Song 108Title, artist, …
Each term-index page holds several sorted terms; each term points to a postings list of song IDs. For Crazy AND Heart, intersect the two lists in RAM to get {12, 108}, then fetch those songs from the data pages. The postings store IDs; the data pages store rows. The B+Tree routing nodes above the term pages are omitted.
Notes ↗
M3

IO Cost & Trade-offs: Choosing Your Index

Each index has costs and benefits.

Index finds topics quickly

Index finds topics quicklyA Python programming book has a Quicksort section on page 201 in its sorting chapter. The separate alphabetical index lists Binary search, Functions, Loops, Quicksort, and Variables. Quicksort points to pages 201 and 208; the arrow leads to page 201. The chapters remain in teaching order.CHAPTER 5 · SORTING ALGORITHMSQuicksortPartition around a pivot.Sort the smaller parts recursively.…201ALPHABETICAL INDEX · BACK OF BOOKBinary search184, 190Functions68, 75Loops35, 48Quicksort201, 208Variables8, 15Look up “Quicksort” → turn to pages 201 and 208 in the book.Python programming book.
A Python programming book organizes its chapters for teaching, not alphabetically by topic. Its back-of-book index sorts topics: “Quicksort” points to pages 201 and 208.
Notes ↗

Sorted pages need only a range index

Sorted pages need only a range indexA dictionary page 128 contains dragon, draw, and dream. A small range index maps door–dozen to page 127, dragon–dream to page 128, and dress–dry to page 129. Since the words are sorted, the index need not list every word. This is an Oxford English Dictionary analogy, not a reproduction of its index.DICTIONARY PAGE · ALPHABETICAL ORDERdragondrawdream128RANGE INDEXWord rangePagedoor–dozen127dragon–dream128dress–dry129“draw” falls in dragon–dream → read page 128, then find the word.Oxford English Dictionary analogy.
Think of the Oxford English Dictionary: its words are already alphabetical. An index can store each page’s word range rather than every word. To find “draw,” choose the dragon to dream range, then search page 128.
Notes ↗

Clustering puts one user’s listens together

Clustering puts one user’s listens together Find all listens for user 7. The same eight rows occupy four pages in either layout, with two rows per page. Arrival order scatters user 7 across four pages. Clustering by user_id and listen_time groups the four matching rows into two pages. Counts exclude index lookup cost. Thin green markers identify matching rows. Rows: user_id · song_id Unclustered · arrival order Page 1 7 · S75 2 · S12 Page 2 3 · S44 7 · S88 Page 3 7 · S12 4 · S99 Page 4 5 · S55 7 · S44 4 data-page reads Clustered · (user_id, listen_time) Page 1 2 · S12 3 · S44 Page 2 4 · S99 5 · S55 Page 3 7 · S75 7 · S88 Page 4 7 · S12 7 · S44 2 data-page reads Index lookup cost excluded. Same matches. Fewer pages.
Find all listens for user_id = 7. Both layouts contain the same eight rows, two per page; the figure shows user_id and song_id. In arrival order, user 7’s four listens occupy four pages. Clustering by (user_id, listen_time) groups them onto two neighboring pages, in time order. That is four versus two data-page reads, excluding index lookup cost. The index finds the rows; clustering puts them on fewer pages.
Notes ↗
M3

Case Study 3.4: How Spotify Wrapped serves a launch-day spike

Spotify Wrapped computes each user's yearly summary before launch and stores the result by user, so the launch-day spike serves precomputed records.

Precompute summaries, then look them up

Precompute summaries, then look them up Chronological storage scatters one user's listens across the disk and requires one thousand random reads in this example. Clustering the Listens table by user_id and listen_time groups that user's plays on neighboring pages. Before launch, the batch job reads them sequentially and writes one Wrapped summary row per user. On December 1, this example fetches the card with one hash-index lookup on user_id. Raw listens chronological order Pg 12 user 3 · 09:14 Pg 47 user 7 Pg 88 user 1 · 11:02 Pg 213 user 7 Pg 250 user 4 · 22:30 Pg 901 user 7 1,000 scattered page reads Cluster by user then read in time order Pg 5 user 7 · 02:11 Pg 5 user 7 · 07:48 Pg 5 user 7 · 18:30 Pg 5 user 7 · 21:05 one sequential scan per user Before Dec 1:compute each user’s summary. write summary Wrapped table one summary per user user_id → summary user 1 → {top 5, 8.1k min} user 3 → {top 5, 2.4k min} user 7 → {top 5, 41k min} user 4 → {top 5, 5.7k min} Hash index on user_id tap user 7 Your Wrapped Top artist: … 41,000 minutes Top 0.5% listener 1 hash lookup Dec 1 · 700M users
The design clusters Listens by (user_id, listen_time), so a batch job reads each user's year sequentially and writes one summary row per user. On December 1, the card fetch uses one hash-index lookup on user_id.
Notes ↗
M3

How Three Changes Cut One Query from $550,000 to $400

A company runs one weekly aggregation 100 times a day against a 1 TB table.

Four problems: reduce the cost of the same query

Songs: 400 pages. Listens: 16,000 pages. Buffer: 20 pages. This week's listens: 150 pages.

Problem 1 · Baseline join

What does a Hash Partition Join cost? Would a Block Nested Loop Join be cheaper?

Problem 2 · Skip irrelevant dates

Only 150 Listens pages match the week. Which index would help, and which pages must we still read?

Problem 3 · Skip unused columns

The query uses only a few columns. How could we avoid reading the rest of each row?

Problem 4 · Store fewer bytes

Genres and ratings repeat. Which lossless encodings could shrink these columns?

Notes ↗

Read fewer pages to cut query cost

Read fewer pages to cut query cost The same weekly aggregation in four stages, left to right. A Hash Partition Join scans the full 1 TB at 49,200 IOs and about $550,000 a year. A B+Tree index cuts the query to about 554 IOs, columnar storage cuts it to about 60 IOs, and compression cuts it to about 35 IOs. The endpoint ratio gives roughly 1,400 times fewer IOs and about $400 a year. Color key: red marks the costly baseline, green marks the optimized result, and grey marks intermediate steps and structure. Baseline · HPJ scan all 1 TB, every column 49,200 IOs $550,000 / yr + B+Tree Index skip to the 1% in date range ~554 IOs ~$6,500 / yr + Columnar Store Read only required columns ~60 IOs ~$730 / yr + Compression dictionary · bit-pack · RLE ~35 IOs ~$400 / yr Fewer rows × fewer columns × fewer bytes ≈ 1400× fewer IOs.
Four stages of the same weekly aggregation. The Hash Partition Join costs 49,200 IOs. A B+Tree index cuts the query to about 554 IOs, columnar storage cuts it to about 60, and compression cuts it to about 35. The endpoint ratio gives roughly 1,400× fewer IOs and lowers annual cost from about $550,000 to about $400.
Notes ↗
M3

Query Optimizer & Statistics

The optimizer estimates the cost of alternative plans and chooses the cheapest.

Choose a cheaper plan for the same answer

Choose a cheaper plan for the same answer One left-to-right pipeline of five stages. Stage 1: SQL is declarative, it states the result, not the procedure. Stage 2: parse and bind turn the text into a validated tree, resolving names and types against the catalog. Stage 3: the logical plan applies equivalence-preserving relational-algebra rewrites (predicate pushdown, projection pruning, subquery flattening); this defines the result, every rewrite returns the same rows. Stage 4, the focal stage: the physical-plan search picks a join order, join algorithm, and access path, using dynamic programming that computes the best sub-plan for each subset of tables once and reuses it, so it never enumerates all n factorial orders, and costs each from table statistics; this is where speed is decided. Stage 5: the chosen operator tree executes, routinely about 100 times faster than the naive ordering. Because the logical plan and every plan the search considers are result-equivalent, only speed varies, never the answer. Color key: mint is the physical-plan cost search where speed is decided; grey is the parse, rewrite, and execute scaffolding. 1 · SQL (declarative) SELECT … FROM Users JOIN Listens JOIN Songs WHERE … GROUP BY … says what, not how 2 · Parse + bind text → AST (parse tree) resolve table / column names & types vs catalog syntactically valid 3 · Logical plan algebra: σ π ⋈ γ equivalence-preserving rewrites: predicate pushdown, prune cols, flatten subqueries defines the result 4 · Physical: cost search pick join order, algorithm, access path. Orders blow up: 3!=6 … 10!=3.6M dynamic programming: best sub-plan per table subset, reused (n! → ~2ⁿ); cost each from stats same result, just faster 5 · Execute run the chosen operator tree ~100× vs naive
Preserve the query’s result; choose how to compute it using estimated costs.
Notes ↗
M3

IO Cost Summary: Comparing Query Plans

Add up each plan’s I/O costs.

One query: join, group, then sort

-- Count recommendation-listen matches for each song.
SELECT r.song_id, COUNT(r.recommendation_id) AS recommendation_count
FROM Recommendations AS r
-- Goal 1: match the same user and song.
JOIN Listens AS l ON r.user_id = l.user_id AND r.song_id = l.song_id
-- Goal 2: count matching pairs per song.
GROUP BY r.song_id
-- Goal 3: show the largest counts first.
ORDER BY COUNT(r.recommendation_id) DESC;
Notes ↗

Which listens match a recommendation?

Recommendations

recommendation_id user_id song_id
10 1 497
11 2 497
12 1 88
13 3 75
… … …

Listens

listen_id user_id song_id
1 1 497
2 1 497
3 2 497
4 3 75
5 2 88
… … …
Notes ↗

Plan 1: Sort-Merge Join

Plan 1: Sort-Merge JoinGoal 0 identifies input tables 1 and 2. Each following goal shows its numbered output table and I/O cost. Badges identify tables, not row counts. Operation I/O cost Output table Goal 1 · Join (T1) and (T2) Match (song_id, user_id) Joined rowsSorted by (song_id, user_id)One row per matching pair T3SMJ(T1, T2) Goal 2 · Group by song_id Read (T3); count matches per song Counts per songsong_id, recommendation_countOne row per matched song T4Cr × P(T3) + Cw × P(T4) Goal 3 · Sort (T4) by count Largest count first Final resultsong_id, recommendation_countDescending recommendation_count BigSort(T4) Total cost SMJ(T1,T2) + Cr × P(T3) + Cw × P(T4) + BigSort(T4) Goal 0 · Input tablesT1Recommendations (R)T2Listens (L)T1, T2, … identify tables. P(T3) is the number of pages in table T3.
Sort-merge join leaves rows in song order, so grouping needs no repartitioning.
Notes ↗
end of lecture 1~69 min of figures

Plan 2: Hash-Partition Join

Plan 2: Hash-Partition JoinGoal 0 identifies input tables 1 and 2. Each following goal shows its numbered output table and I/O cost. Badges identify tables, not row counts. Operation I/O cost Output table Goal 1 · Join (T1) and (T2) Match (song_id, user_id) Joined rowsPartitioned by (song_id, user_id)One row per matching pair T3HPJ(T1, T2) Goal 2 · Group by song_id Repartition (T3) by song_id Repartitioned joined rowsAll rows for a song in one partition T3bHP(T3) Read (T3b); count matches per song Counts per songsong_id, recommendation_count T4Cr × P(T3b) + Cw × P(T4) Goal 3 · Sort (T4) by count Largest count first Final resultsong_id, recommendation_countDescending recommendation_count BigSort(T4) Total cost HPJ(T1,T2) + HP(T3) + Cr × P(T3b) + Cw × P(T4) + BigSort(T4) Goal 0 · Input tablesT1Recommendations (R)T2Listens (L)T1, T2, … identify tables. P(T3) is the number of pages in table T3.
Hashing by (song_id, user_id) requires a repartition by song_id in this plan.
Notes ↗

Choose the plan with the smaller total cost

  • Plan 1: SMJ(T1,T2) + C_r × P(T3) + C_w × P(T4) + BigSort(T4)

  • Plan 2: HPJ(T1,T2) + HP(T3) + C_r × P(T3b) + C_w × P(T4) + BigSort(T4)

Do the math: calculate both total costs and choose the lower-cost plan.

Notes ↗

Combine operators into a plan

Combine operators into a planRead, join, group, and sort the recommendation query. Choose alternatives for each operation; partitioning and sorting support operators when needed.ReadTable scanIndex scan→JoinHash joinSort-merge joinNested loop→GroupHash aggregateSorted aggregate→SortSort by countChoose the lowest estimated total cost.
For the recommendation query, choose access methods, a join, an aggregation method, and a final sort by count. Partition or sort only where an operator needs it. Compare the complete plans using estimated rows, pages, memory, and existing order.
Notes ↗
M3

Reading PostgreSQL EXPLAIN

EXPLAIN shows PostgreSQL’s chosen plan and estimated costs.

What PostgreSQL actually chose

What PostgreSQL actually choseActual PostgreSQL 17.9 EXPLAIN for the recommendation query. Scans, Hash Join, HashAggregate, Sort. Local Colab schema and data with four added recommendation rows; ANALYZE before planning.Same recommendation querySort (cost=2.38..2.39 rows=3 width=12) Sort Key: (count(r.recommendation_id)) DESC -> HashAggregate (cost=2.32..2.35 rows=3 width=12) Group Key: r.song_id -> Hash Join (cost=1.10..2.31 rows=3 width=8) Hash Cond: ((l.user_id = r.user_id) AND (l.song_id = r.song_id)) -> Seq Scan on listens l (cost=0.00..1.09 rows=9 width=8) -> Hash (cost=1.04..1.04 rows=4 width=12) -> Seq Scan on recommendations r (cost=0.00..1.04 rows=4 width=12)Read bottom-up.rows = estimated output · cost = planner unitsGroups fit in RAM: no repartition.
Actual PostgreSQL 17.9 output, using the Query Plans Colab’s schema and nine listens plus four added sample recommendations. The original Colab leaves Recommendations empty. After ANALYZE, PostgreSQL chose Hash Join, HashAggregate, and Sort. The groups fit in RAM, so this plan avoids the extra repartition in the hand-worked Plan 2. These tiny-data costs are not predictions for large tables.
Notes ↗

Statistics estimate the work

  1. Sample: inspect a sample of the table (example: 1% of its rows).

  2. Summarize: keep row and page counts, distinct-value counts, common-value frequencies, and histograms of value ranges.

  3. Store and use: save these compact summaries in the database’s system catalog, organized by table and column. The optimizer uses them to estimate output rows and compare plan costs.

Example: for WHERE user_id BETWEEN 1 AND 100, suppose the statistics estimate that 1% of rows match. That is the selectivity. With 1 million input rows, the estimated output cardinality is 1,000,000 × 1% = 10,000 rows.

Notes ↗
M3

Distributed Query Planning

Partition the data, run local operators, and shuffle rows when matching keys must meet.

Send matching keys to the same machine

Send matching keys to the same machine Distributed shuffle: rows starting on multiple nodes are hashed on the join key and sent over the network so all rows with the same key land on the same target node, ready for local join. This is data movement across machines, not a disk or RAM story. Color key: mint follows one key (user_id 42) from every source node, over the network, onto its single colocated node; grey is the other keys and the node structure. Before shuffle: rows scattered across 3 nodes Node A user_id 42 · row user_id 7 · row user_id 13 · row Node B user_id 7 · row user_id 42 · row user_id 13 · row Node C user_id 13 · row user_id 7 · row user_id 42 · row shuffle: send each row to h(user_id) % 3 over the network After shuffle: same key on the same node, ready for local join Node A · all user_id 42 42 · 42 · 42 → local join Node B · all user_id 7 7 · 7 · 7 → local join Node C · all user_id 13 13 · 13 · 13 → local join
Rows begin scattered across multiple nodes. Hashing each row on the join key picks its target machine, so every row sharing a key travels over the network to the same node. Once the keys sit together, each node runs an ordinary local join on the rows it now owns. This is exactly hash partitioning with the network as the destination, so the cost is the same 2N IO plus the network transfer.
Notes ↗
M3

Spotify End-to-End: How Everything Connects

Follow a play event through writes, batch processing, and analytics.

One click connects OLTP, ETL, and OLAP

One click connects OLTP, ETL, and OLAP Spotify end-to-end architecture: one click triggers three systems. Act 1 OLTP (LSM tree write), Act 2 ETL (Spark hash partition, BigSort, broadcast join, columnar write), Act 3 OLAP (star schema query with broadcast and columnar scan). All three are persistent storage systems on disk. Color key: mint marks the durable on-disk storage system at each act; red marks the volatile RAM MemTable on the OLTP hot path before it persists to disk. OLTP · Record the play LSM Tree Write 1. MemTable (in RAM) 2. Flush to SSTable (disk) 3. Background compaction < 8 ms · 100K writes/sec · 8.6B/day real-time events queue ETL · Transform events Spark Pipeline 1. Hash partition by user_id 2. BigSort by timestamp 3. Broadcast join dim tables 4. Write columnar (Parquet) 3 hr · 1000 nodes · 100 TB/day nightly batch load OLAP · Query the data Star Schema Query 1. Broadcast small dims 2. Columnar scan fact 3. Two-phase aggregation 4. Final sort + LIMIT 2 sec · 3 of 20 cols · 1 PB warehouse Fast writes → batch processing → efficient scans
LSM writes feed a batch pipeline that produces columnar data for analytics.
Notes ↗
M3

Module 3 Capstone: Picking the Right Algorithm

Choose the data structure and algorithm that fit each workload.

Which join algorithm should I use?

  1. Non-equi joins: use BNLJ.

  2. Equi-joins: use the equation sheet to calculate BNLJ, HPJ, and SMJ costs. Choose the cheapest.

Do the math.

Notes ↗

Choose the key, then the index

Choose the key, then the indexWords and IDs can be indexed directly. Embeddings can be full keys for exact matches or mapped to LSH codes for similarity candidates. Locations can be mapped to spatial codes. Hash indexes support equality; B+ trees also support ordered key ranges. Original valueKey to index Word or user IDUse the original keyEmbedding (e.g., 1 KB)Full key: exact matchingOr LSH code: similarity candidatesLocation coordinatesSpatial code (e.g., geohash)Nearby-region candidates Hash indexExact key or bucket lookup B+ treeExact key lookup or ordered key ranges For LSH or spatial codes, check candidates’ actual similarity or distance. Ordering an embedding’s bytes does not preserve vector proximity.
Smaller keys fit more entries per page.
Notes ↗

Update pages or batch writes?

B+ tree / hash index LSM tree
Update the relevant pages Buffer updates in memory
Split pages or buckets; reorganize as needed Flush sorted SSTable files
Scattered page writes can become expensive Batch writes, then merge files through compaction
Pay for page updates as writes arrive Reads may check multiple files; compaction adds work

Update pages now, or batch writes and compact later.

Notes ↗
M3

Systems Design Problems

Finding the Right Data

How do you find upcoming shows in your ZIP code?

Find upcoming shows in one exact ZIP code. Return artist, venue, and date.

Consider:

  • A Hash index
  • B B+ tree
  • C LSH
  • D LSM tree
  • E Bloom filter

Hint

Use a hash index. What is the lookup key?

One possible design

Key/value:zip_code → show records with artist, venue, and date.

How it works:find the ZIP’s records, filter out past shows, and sort by date.

Tradeoff:the ZIP lookup is fast; filtering and sorting still take work.

Notes ↗

How do you show an artist’s listening trends?

Show daily listen totals after a release for one artist and city over a date range. Updates may lag by 1 hour.

Consider:

  • A Hash index
  • B B+ tree
  • C LSH
  • D LSM tree
  • E Bloom filter

Hint

Store daily aggregates with a B+ tree. What comes before the date in the key?

One possible design

Key/value:(artist_id, city, date) → daily listen count.

How it works:Update daily totals hourly. Seek to the first date and scan the range.

Tradeoff:Keep raw events for questions the daily totals cannot answer.

Notes ↗

How do you keep listen time per song up to date?

Each listen adds listen time. Handle 100,000 events per second. Totals may lag by 1 minute.

Consider:

  • A Hash index
  • B B+ tree
  • C LSH
  • D LSM tree
  • E Bloom filter

Hint

Use an LSM tree with an additive merge rule. Choose the key and value.

One possible design

Key/value:(song_id, date) → partial sum of seconds.

How it works:accumulate in memory, flush sorted files, and add partial sums during reads and compaction.120 + 80 + 100 = 300 seconds

Tradeoff:Compaction still costs I/O.

Notes ↗

How do you maintain average listen time per song?

New listens keep arriving. Maintain the overall average without rereading every listen.

Consider:

  • A Hash index
  • B B+ tree
  • C LSH
  • D LSM tree
  • E Bloom filter

Hint

Keep the LSM design. What two numbers can you add independently?

One possible design

Store:(total_seconds, listen_count) per song and date.

Combine:add the seconds and counts.

Read:divide total seconds by listen count.

Notes ↗

How do you avoid reading unnecessary files?

A song’s totals may appear in several SSTables. How can you skip files that definitely do not contain its key?

Consider:

  • A Hash index
  • B B+ tree
  • C LSH
  • D LSM tree
  • E Bloom filter

Hint

Use a Bloom filter per SSTable. Which keys should each filter summarize?

One possible design

Keys summarized:each SSTable’s (song_id, date) keys.

How it works:Definitely absent → skip the file. Possibly present → check its index and data.

Tradeoff:filters use RAM; false positives cause unnecessary reads.

Notes ↗

How do you find 20 album covers similar to this one?

Given a cover or cover ID, find 20 similar images among 10 million covers. Their embeddings are indexed using LSH. Approximate results are acceptable.

Consider:

  • A Hash index
  • B B+ tree
  • C LSH
  • D LSM tree
  • E Bloom filter

Hint

Use LSH for candidates, then score them. What belongs in each bucket?

One possible design

Key/value:LSH_code → cover IDs, within each LSH table.

How it works:get the cover’s embedding, compute its LSH codes, and probe matching buckets. Deduplicate candidates, score by cosine similarity, and return the closest 20 candidates.

Tradeoff:the true closest matches may be missed. Probing more buckets improves coverage but adds work.

Notes ↗
end of lecture 2~24 min of figures
M3 nanoDB: storage, algorithms, indexing, optimization 1 of 36
▶ Y Notes □ keys

M3 nanoDB: storage, algorithms, indexing, optimization

How a database actually runs your query · 36 figures · ~92 min · 2 lectures at this pace