catalogue / databases

Databases

Where most real production incidents actually live.

9 of 9 topics live on this page

Loading a page from the database

One page with 3 lists on it, where every row needs a related record (say, each order's customer). Lazy loading fetches that record separately for every row: the N+1 query an ORM makes easy to write. The other three fetch the same data in a handful of trips, whatever the row count.

Assumes each database query costs a 0.8 ms round trip plus 0.15 ms to run, one after another

Rows on the page

round trip 0.8 ms query 0.15 ms lists 3

  • faint tick: one trip to fetch a list
  • solid tick: one trip to fetch related rows
ApproachRound trips so farPage load time
Lazy loading97.9 ms
Batched IN5.7 ms
Dataloader3.8 ms
One join per list2.85 ms

Batched IN and the dataloader both make one extra trip for related rows; the difference is when. Batched IN makes it after each list (3 extra trips); the dataloader waits until every list has loaded and makes it once for the whole page. Lazy loading waits 34× longer than the joins for the same page.

Finding a customer's orders, with and without an index

The same query on an orders table, run two ways. Without an index the database reads every row to find one customer's 8 orders, so the work grows with the table; with an index on customer_id it walks a B-tree only 2 to 4 pages deep and jumps straight to them, so a table 300 times bigger costs one more page read.

Assumes 100 rows per 8 KB table page, ~300 keys per index page, 8 orders per customer each on its own page, 10 µs to read the next page in order and 100 µs to jump to any page (nothing cached). Playback time grows with the log of real time.

Rows in the table

SELECT * FROM orders WHERE customer_id = 4217; -- 8 rows of 4,000,000, each dot ≈ 285,714 rows

No index: sequential scan O(n) scanning reads every page of the table in order and checks every row
Index on customer_id O(log n) probing walks the B-tree root to leaf, then jumps to each matching row; 300 keys per page, 3 levels deep

rows/page 100 fan-out 300 orders/customer 8 in order 10 µs jump 100 µs

  • a dot is a slice of the table's rows
  • rows examined, in the plan's colour
  • a row with customer_id = 4217

At 4,000,000 rows the index reads 11 pages instead of 40,000: about 364× less time for the same answer.

Rows examined, pages read and time for one lookup, at 10 µs per page read in order and 100 µs per page jumped to
Plan1,000 rows100,000 rows4,000,000 rows100,000,000 rows
No index: sequential scan1,000 rows 10 pages, 100 µs100,000 rows 1,000 pages, 10 ms4,000,000 rows 40,000 pages, 400 ms100,000,000 rows 1,000,000 pages, 10 s
Index on customer_id8 rows 10 pages, 1 ms8 rows 11 pages, 1.1 ms8 rows 11 pages, 1.1 ms8 rows 12 pages, 1.2 ms

Page 10,000 of a paginated list

A reader has paged deep into a list sorted by id, 20 rows a page. With OFFSET the database has to walk past every row on every earlier page and throw them away before it reaches the 20 it returns; keyset pagination remembers the last id it showed and lets the index jump straight there. The top lanes play each query in real time; the chart shows the rows read for every page.

Assumes an index on id, 1 µs per row read, 0.1 ms fixed cost per query, and a 2,000,000-row table

Page number

page size 20 rows table 2,000,000 rows per row 1 µs per query 0.1 ms

  • read and thrown away
  • the 20 rows returned
  • never read
QueryRows readThrown awayTimeStatus
OFFSET200,000199,980200 msnoticeable
Keyset200120 µsinstant

On page 10,000, OFFSET reads 200,000 rows and throws away 199,980 of them to show 20: 10,000× the work of the keyset query, and 20 rows more for every page deeper. The keyset query costs the same on every page; the price is that it can only step to the next page, not jump to page 10,000 by number.

The total on a paginated list

A list page shows 20 rows and a “showing 1-20 of N” badge. The rows come from an index and cost the same however many match; the exact total has to visit every matching row, so past 4,000 rows the badge is the slow part of the page.

Assumes Postgres, where MVCC means an exact count must check every matching row: 0.3 ms per round trip, 10 µs per row the page fetches through its index, 50 ns per row the count checks (in memory, one core, no parallel workers)

Matching rows
Orders status = shipped showing 1-20 of 0…
  1. #4,265,795Ada L.$142.05
  2. #4,265,794Grace H.$62.86
  3. #4,265,793Alan T.$171.67
  4. #4,265,792Edsger D.$92.48
  5. #4,265,791Barbara L.$13.29
  6. #4,265,790Ken T.$122.10
  7. #4,265,789Donald K.$42.91
  8. #4,265,788Radia P.$151.72
  9. #4,265,787Linus T.$72.53
  10. #4,265,786Frances A.$181.34
  11. #4,265,785Ada L.$102.15
  12. #4,265,784Grace H.$22.96
  13. #4,265,783Alan T.$131.77
  14. #4,265,782Edsger D.$52.58
  15. #4,265,781Barbara L.$161.39
  16. #4,265,780Ken T.$82.20
  17. #4,265,779Donald K.$191.01
  18. #4,265,778Radia P.$111.82
  19. #4,265,777Linus T.$32.63
  20. #4,265,776Frances A.$141.44
with an estimate instead showing 1-20 of …

page 20 rows round trip 0.3 ms page row 10 µs count row 50 ns

  • page query
  • exact count
  • estimate
RequestSQLRows readTime
Page queryORDER BY id DESC LIMIT 2020500 µs
Exact countSELECT count(*)4,265,795214 ms
Estimatereltuples from pg_class1300 µs

At 4,265,795 matching rows the exact count takes 214 ms, 427× the page it sits on noticeable. The estimate is one catalogue lookup whatever the size. Or drop the total: fetch 21 rows, show 20, and offer “next” only if the extra one came back (“1-20 of many”), which costs nothing beyond the page query.

Fetching every column for a three-column page

A product grid that renders a name, a price and a thumbnail, loaded with SELECT * and with those three columns named. The top bars are one row drawn to scale by bytes; below, the whole page crosses the wire. SELECT * also drags along a 2 KB description and a 5.8 KB specs blob that the database first has to fetch from its TOAST table, and both costs grow in a straight line with the rows.

Assumes the column sizes shown, the Postgres wire format (7 B per row, 4 B per value), a 1 Gbit/s link, and 5 µs to fetch and decompress each 1,996 B TOAST chunk, done before the row is sent; the round trip, the same for both, is left out

Rows per page

description 2,000 B specs 5,800 B link 1,000 Mbit/s TOAST chunk 5 µs

  • SELECT *
  • SELECT name, price, thumbnail_url
  • bytes sent, never shown
  • specs_json: fetched from TOAST, then sent
  • not sent yet
QueryPageShownWireTOASTTotalReceivedStatus
SELECT *802 KB1.6%6.42 ms1.5 ms7.92 msinstant
SELECT name, price, thumbnail_url14.7 KB87%118 µsnone118 µsinstant

At 100 rows, SELECT * sends 802 KB to put 12.8 KB on the page, and takes 67× as long as naming the three columns; 19% of that time is the database fetching specs_json from TOAST. Both lines are straight: ten times the rows, ten times the bytes.

A join that forgets its condition

Orders joined to their shipments, running for real on 24 rows each, one line per row the query returns. With the join condition each order meets only its own shipment; mistype one alias so the condition compares the shipment with itself and it is true for every pair, so every order is joined to every shipment and the rows returned grow with the square of the table size.

Real joins over 24 orders with one shipment each, counted as rows come out, both at the same rows per second. The table assumes 200 bytes and 1 µs per returned row

Rows in each table

10,000 × 10,000 = 100,000,000 rows where 10,000 were wanted

With the join condition joining 0 / 24 rows returned
SELECT o.id, s.tracking_no
FROM orders o
JOIN shipments s
  ON s.order_id = o.id
Condition compares s with itself joining 0 / 576 rows returned
SELECT o.id, s.tracking_no
FROM orders o
JOIN shipments s
  ON s.order_id = s.order_id

sample 24 rows row 200 B per row 1 µs

  • one line per row returned: each order with its own shipment
  • one line per row returned: every order with every shipment
10,000 orders and 10,000 shipments, at 200 bytes and 1 µs per row returned
Join conditionRows returnedData sentTimeStatus
ON s.order_id = o.id10,0002 MB10 msinstant
ON s.order_id = s.order_id100,000,00020 GB1.67 minnever ships

Many requests bumping one counter

Every request runs the same UPDATE on one page's view counter. The database lets only one transaction hold that row's lock at a time, so the row commits at most 500 increments a second however many writers pile on, and past about 26 writers each extra one only lengthens the queue. Splitting the counter over 8 rows and summing them on read raises the cap 8-fold: the same cliff, further out.

Assumes each writer does 50 ms of other work, then holds the row lock 2 ms per increment including the commit (both vary randomly around those means). The lanes run that simulation for real, slowed 40×; the chart is its exact average.

Concurrent writers
One counter row 0 waiting for a lock UPDATE stats SET views = views + 1 WHERE page_id = 1
8 counter rows, summed on read 0 waiting for a lock UPDATE stats SET views = views + 1 WHERE page_id = 1 AND shard = floor(random() * 8)

lock hold 2 ms other work 50 ms shards 8 rows

Slowed 40×: each dot is one writer; a 2 ms lock hold lasts 80 ms on screen.

  • one counter row
  • 8 counter rows, summed on read
CounterMax incrementsIncrementsUPDATE latencyWriters waitingStatus
One counter row500/s500/s350 ms174at the cap
8 counter rows, summed on read4,000/s3,298/s10.6 ms28queue forming

How many indexes is too many

One orders table taking as many INSERTs as reads. Each index you add lets another kind of query jump straight to its row instead of scanning the whole table, but every INSERT now writes one more index page. Read work falls, write work climbs, and the total bottoms out at 6 indexes.

Assumes a 10,000-row table (100 pages), 50% reads and 50% INSERTs, 12 query shapes each 0.5× as common as the last, index k serving shape k; a scan reads all 100 pages, an index lookup 2, an INSERT writes 1 page plus 1 per index

Indexes on the table
  • a page written by an INSERT
  • a page read by a SELECT
  • triangle: an index on that column (dashed: not built)
  • reads: pages per 100 operations
  • writes: pages per 100 operations
  • total: pages per 100 operations
IndexesReads that scanPages per readPages per INSERTPages per 100 opsStatus
This setting61.5%3.57525lowest total
No indexes0100%100.015,050reads scan
Best61.5%3.57525lowest total
Every column120%2.013750over-indexed

Index number 7 would serve a query that is only 0.78% of reads, and every INSERT would pay one more page write for it. The best count is not where the two lines cross (between 4 and 5): it is where one more index saves less read work than it adds in writes.

When the connection pool runs dry

500 request threads share 100 database connections, simulated live. Each connection frees up about every 1 s, so the pool can serve 100 requests a second. Past that, threads queue inside your app, not in the database, and any thread that waits 2 s fails with an acquire timeout, not a clear 'pool full' error. At 95 requests/s the average wait is 101 ms; at 105 it is 1.81 s.

Simulated for real: random arrivals, each request holds its connection for 1 s on average, first come first served, 2 s acquire timeout. Curves: the same queue solved exactly (M/M/100 with a 2 s deadline)

Requests per second

threads 500 pool 100 hold 1 s timeout 2 s

  • free thread
  • thread waiting for a connection
  • thread holding a connection
  • timed out
connections free 0 served 0 timed out
  1. no errors yet
  • exact model
  • the simulation above, last 5 s

The pool never refuses a request. A thread that asks for a connection while all 100 are busy is parked and blocks; nothing fails until its 2 s are up, so what the on-call engineer sees is an acquire timeout, while the database runs exactly 100 queries and looks healthy. Past 100 requests/s every extra request waits the full 2 s and then fails: at 150/s that is 33% of all requests.