Skip to content

The database scaling playbook

Spend in order of cost: every step has a trigger you can measure, and sharding comes last because it breaks joins, transactions and uniqueness.

Predict first

Before you read: predict

A catalog's Postgres primary reaches 90% CPU every evening, and product pages slow down. Reads are 95% of its statements; writes stay at a few hundred a second. Four proposals are on the table.

Which should come first?

Pick one

The 30-second version

Scale a database in order of cost; take each step only when its trigger shows. Measure first: pg_stat_statements ranks statement shapes by calls and rows. Tune the top ones before buying hardware. Then fit connections under the server's limit, cache hot unchanging reads, replicate lag-tolerant reads, scale up, partition huge tables, and shard only for writes one primary cannot take.

Spend in order of cost: every step has a trigger you can measure, and sharding comes last because it breaks joins, transactions and uniqueness.

Step through the code

Trace of the opening question

Play options

Ready: press Play or step with Next frame

Row 1 of 4 · Trigger a saturated server not yet shown to be tuned

Starting row · computed by comparing each row with the one before

  • Trigger: a saturated server not yet shown to be tuned
  • The probe's stem: 90% CPU; nothing says tuned
  • Met?: yes: this comes first

Rule · step 1

Measure first. pg_stat_statements keeps one entry per statement shape, with calls (executions) and rows (total rows returned or affected).

Rule · step 2

Constants become $1, $2, so one shape with different values adds up in one entry. A bound parameter and a pasted constant are different shapes.

Rule · step 3

Tune the most-called and most expensive statements first. An N+1 or a missing index is fixed in code, not with hardware.

Step
1–3 measure, tune
Trigger
a saturated server not yet shown to be tuned
The probe's stem
90% CPU; nothing says tuned
Met?
yes: this comes first
Trace of the opening question
StepTriggerThe probe's stemMet?
1–3 measure, tunea saturated server not yet shown to be tuned90% CPU; nothing says tunedyes: this comes first
7 replicasreads dominate after tuning (changed)reads 95%, not after tuning (changed)not yet (changed)
8 scale uptuned statements still saturate (changed)not after tuning (changed)not yet
10 shardwrites exceed one primary (changed)a few hundred writes a second (changed)no (changed)

Space plays or pauses · ←/→ one row · Home/End first/last

Rows come from the pack's own trace table: the opening question's stem against the triggers, in order; derivations.mjs.

How it works

Step 1 of 10

Measure first. pg_stat_statements keeps one entry per statement shape, with calls (executions) and rows (total rows returned or affected).
See it run
In the kitchen
  • reading the district meters first → pg_stat_statements (steps 1–2)

Analogy: A small town's water utility

Pressure drops every evening. The engineer reads the district meters before spending anything, and finds one leaking main and a street of running taps.

At the water utility In the database
reading the district meters first pg_stat_statements (steps 1–2)
fixing the leak costs a plumber, not a pump tuning the top statements (step 3)
the main feeds only so many service pipes; new blocks share a header tank the connection budget and a pooler (steps 4–5)
a roof tank for water drawn over and over a cache for hot reads (step 6)
a downstream reservoir, refilled hours behind, for drinking taps only a replica: lagging, read-only (step 7)
a bigger pump at the works scaling up (step 8)
pressure zones inside one network partitions on one server (step 9)
a second utility across the river a shard (step 10)

In real terms: each step costs more than the one before, so you take it only when a measurement shows its trigger.

Breaks down when: a town can run two utilities that never trade water. Shards of one database usually share customers, orders and reports, so every cross-town street needs a transfer agreement. Wrong inference: "a second utility just doubles capacity." Sharding also adds fan-out queries and distributed commits.

Contrasting case: a utility that builds a second reservoir while the leaking main still runs. More capacity arrives, and so does the same waste.

Examples, run for real

Every example ran on a real PostgreSQL 17.10 in the pack's own container, with pg_stat_statements preloaded and reset first. No output carries a time.

4.1 ExamHall: the live feed stalls

EXH-1700: at 09:00 the proctor console's live feed stalled for 60 proctors, and the team asked for read replicas. First, pg_stat_statements, after 120 console refreshes:

4.1 after 120 console refreshes, pg_stat_statements by calls:
  calls 4800 · rows 4800 · SELECT status FROM candidates WHERE id = $1
  calls 120 · rows 6000 · SELECT id, candidate_id, kind FROM alerts WHERE proctor_id = $1 ORDER BY id DESC LIMIT $2
  tile lookups per feed query: 40

The status-tile lookup runs once per candidate tile, 40 times per feed query: it dominates by calls (steps 1–2). The next step is fixing that statement, which MR !1900 does (db-n-plus-one owns how), not replicas (step 3).

4.2 ExamHall: ten instances do not fit

EXH-1701: for the live feed, the proctor API grew from 6 to 10 instances, each with a pool of 20. Read the budget from the server, then fill it as an ordinary role:

4.2 PostgreSQL 17.10: SHOW max_connections 150 · superuser_reserved_connections 3 · reserved_connections 0
  budget for the app role: 150 - 3 - 0 = 147 · filled: 147 accepted, then "remaining connection slots are reserved for roles with the SUPERUSER attribute"
  6 instances x pool 20 = 120 -> fits
  10 instances x pool 20 = 200 -> over the budget

The budget is 147, not 150: three slots wait for superusers (step 4). Two hundred is over it, so the connection step's trigger is met. MR !1901 puts a transaction pooler in front (step 5); concepts/db-connection-pooling.md owns what transaction mode changes.

4.3 Proof domain, catalog: what a shard cannot check

The catalog's customers.email is UNIQUE. Two signups use the same email, once on one database and once on two shards routed by customer id:

4.3 one database: 41 accepted · 42 refused 23505
4.3 two shards by id % 2: 41 -> shard_1 accepted · 42 -> shard_0 accepted
4.3 who holds [email protected]? shards asked 2 of 2, rows found 2

Each shard checks only its own rows, so the duplicate got in, and finding one email asks every shard (step 10). Two databases on one server stand in for two servers (model).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

At the evening peak, product pages time out. The catalog's statements are already tuned. On the primary, SHOW gives max_connections 200, superuser_reserved_connections 3 and reserved_connections 10. The API runs 8 instances with a pool of 20 each, plus 12 connections for workers and admin. The product-detail lookup is 70% of pg_stat_statements calls; the API's access log shows 3,000 product ids taking 92% of those lookups, and the price job updates each of those rows about twice a day. CPU sits at 90%.

Which step is next?

Pick one

Question 2 of 5

A category page runs SELECT id, name, price FROM products WHERE category_id = 3 ORDER BY id LIMIT 24, with the category id pasted into the SQL text; every run returns 24 rows. After pg_stat_statements_reset(), shoppers open the pages of 5 categories 40 times each.

What does pg_stat_statements show for this query?

Pick one

Question 3 of 5

The catalog's orders database takes every checkout. Its statements are tuned, and it already runs on the largest instance class the provider offers. At the evening peak its CPU sits at 95%, and inserts and updates of orders make up 70% of its statements. The orders table is 300 GB; the instance has 768 GB of memory. Shoppers reload an order's page often while it is prepared, and each order row changes every few minutes until it ships.

Which step is next?

Pick one

Question 4 of 5

The catalog team's scaling plan lists four steps, each with the measurement that justified it.

Which step is not justified by its measurement?

Pick one

Question 5 of 5

The catalog is split over two shards: each order line is stored on its customer's shard, and each product on its merchant's shard. On one server, the best-sellers report SELECT count(*) FROM order_lines l JOIN products p ON p.id = l.product_id counts 600. Half of each shard's order lines are for a product stored on the other shard. The router sends the report to both shards and adds up their counts.

What total does it return?

Pick one

Go deeper

Pro tips
  1. Preload pg_stat_statements before you need it. The view reads only when the module is loaded at server start; CREATE EXTENSION alone succeeds and then fails on the first read. When: setting up a new database, before the first incident (step 1). Verify (RAN, PostgreSQL 17.10, tips.mjs, 50 + 50 runs, 1 distinct):
    tip 1 not preloaded: CREATE EXTENSION ok · SELECT from the view: "pg_stat_statements must be loaded via "shared_preload_libraries""
  2. Check pg_stat_statements_info.dealloc before trusting a ranking. Past pg_stat_statements.max distinct shapes (default 5,000), the least-executed entries are discarded, and dealloc counts each discard. When: a busy server whose ranking looks thin (step 1). Verify (RAN, tips.mjs):
    tip 2 pg_stat_statements.max 100, 150 distinct shapes run once each: entries kept ≤ 100: true · dealloc > 0: true
Source

Checked against the tools and docs as of 2026-09-29.