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?
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
| Step | Trigger | The probe's stem | Met? |
|---|---|---|---|
| 1–3 measure, tune | a saturated server not yet shown to be tuned | 90% CPU; nothing says tuned | yes: this comes first |
| 7 replicas | reads dominate after tuning (changed) | reads 95%, not after tuning (changed) | not yet (changed) |
| 8 scale up | tuned statements still saturate (changed) | not after tuning (changed) | not yet |
| 10 shard | writes 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
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?
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?
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?
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?
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?
Go deeper
Pro tips
- Preload
pg_stat_statementsbefore you need it. The view reads only when the module is loaded at server start;CREATE EXTENSIONalone 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"" - Check
pg_stat_statements_info.deallocbefore trusting a ranking. Pastpg_stat_statements.maxdistinct shapes (default 5,000), the least-executed entries are discarded, anddealloccounts 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
- PostgreSQL 17, F.30 pg_stat_statements: https://www.postgresql.org/docs/17/pgstatstatements.html ·
calls"Number of times the statement was executed";rows"Total number of rows retrieved or affected by the statement"; entries combine queries "semantically equivalent except for the values of literal constants";pg_stat_statements.maxdefault 5000 anddealloc. Valid as of 2026-09-29. - PostgreSQL 17, 19.3 Connections and Authentication: https://www.postgresql.org/docs/17/runtime-config-connection.html ·
max_connections("typically 100"),reserved_connections(default 0) andsuperuser_reserved_connections(default 3). Valid as of 2026-09-29. - PostgreSQL 17, 5.12 Table Partitioning: https://www.postgresql.org/docs/17/ddl-partitioning.html · §5.12.1: "Partitioning effectively substitutes for the upper tree levels of indexes"; "a rule of thumb is that the size of the table should exceed the physical memory of the database server." Valid as of 2026-09-29.
- PgBouncer, configuration: https://www.pgbouncer.org/config.html ·
pool_modetransaction: "Server is released back to pool after transaction finishes." Supports step 5; not executed (no PgBouncer image is local). Valid as of 2026-09-29. - AWS, Best practices for Amazon RDS: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_BestPractices.html · "a first step to improve performance is to tune the most used and most expensive queries"; "If your queries are tuned and an issue persists, consider upgrading". Supports steps 3 and 8, t1 and t2. Valid as of 2026-09-29.
- AWS, Working with DB instance read replicas: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReadRepl.html · read-only access, asynchronous copies, read-heavy workloads. Valid as of 2026-09-29.
- AWS, Caching overview: https://aws.amazon.com/caching/ · "A single cache instance can provide hundreds of thousands of IOPS … potentially replacing a number of database instances"; "Storing common keys in an in-memory cache mitigates the need to overprovision". A current page; it replaces the whitepaper below as P1's source for C and D (fix-brief W3B5 ruling 4). Valid as of 2026-09-29.
- AWS whitepaper, Database Caching Strategies Using Redis, "Database challenges": https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/database-challenges.html · "several database read replicas to match what a single in-memory cache node can deliver". The page says it is kept for historical reference, so it keys no item; the current AWS caching page above says the same. Background to step 6. Valid as of 2026-09-29.
- Microsoft Azure Architecture Center, Caching guidance: https://learn.microsoft.com/en-us/azure/architecture/best-practices/caching · "Caching is most effective when a client instance repeatedly reads the same data". Valid as of 2026-09-29.
- Microsoft Azure Architecture Center, Sharding pattern: https://learn.microsoft.com/en-us/azure/architecture/patterns/sharding · "Use this pattern when" and "This pattern might not be suitable when"; cross-shard queries, transactions and unique ids. Valid as of 2026-09-29.
Related lessons
Checked against the tools and docs as of 2026-09-29.