Module 5 / Scaling the database

Module 5 · lesson 1 of 8 · Read 10 min

The database bottleneck

Spotting when the database, not the servers, is the limit.

Two months after the big promotion, Friday dinners are busier than ever. Tiffin's app servers are stateless and autoscale nicely. At 8pm there are 8 of them, each at a relaxed 30% CPU. And yet p99 latency is creeping up, from 300 ms to 900 ms to, last Friday, 2.5 seconds.

Someone does the obvious thing and changes the dinner schedule to start 20 servers instead of 8. (Autoscaling on CPU would never have added them: CPU is fine.) The next Friday is worse.

That's the signature of a different bottleneck. The servers aren't working hard. They're waiting. And every one of them is waiting on the same thing: the one database they all share. This lesson is about recognising that, finding out exactly what the database is struggling with, and knowing what to try first.

The signs

You met the clue in Module 4: low CPU with high latency means the server is waiting on something. When the something is the database, you'll see a pattern like this:

Where you look What you see
App server CPU Low or moderate, 30-50%
App server latency High, and rising with traffic
Request traces Most of each request's time is spent in database calls
Database CPU High, 80% or more
Database connections Near the limit, many waiting
Adding app servers No improvement, or things get worse

Request tracing (Module 11 goes deep on this) is the quickest way to confirm it. A trace for Tiffin's menu endpoint might show:

GET /restaurants/42/menu            total 1,840 ms
  auth check (Redis)                       1 ms
  waiting for a database connection    1,120 ms
  SELECT menu_items ...                  710 ms
  build JSON                               9 ms

The app's own work is 10 ms. The other 1,830 ms is the database, or waiting for a turn to talk to it.

Why more servers made it worse

Adding app servers adds demand on the database without adding any capacity. Each new server opens more connections and sends more queries at once. Past a certain point, a database doing more things at the same time gets less done in total: queries fight over CPU, memory, disk and locks, and the database spends more time switching between them than running them.

It's like adding more cars to a jammed road. The road doesn't get wider.

Look inside the database

Once you know it's the database, the next question is what in the database. Guessing is a waste of time, because the database will tell you.

In Postgres, the pg_stat_statements extension records every distinct query with how often it runs and how long it takes. Sort by total time:

SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;

Here's what Tiffin's database showed for one Friday dinner (simplified):

Query Calls Mean time Total time
SELECT ... FROM menu_items WHERE restaurant_id = $1 8.1 M 2.9 ms 23,500 s
SELECT ... FROM orders WHERE customer_id = $1 ORDER BY ... 1.1 M 14 ms 15,400 s
SELECT status ... FROM orders WHERE id = $1 10.8 M 0.8 ms 8,600 s
UPDATE riders SET lat = $1, lng = $2 ... 6.5 M 0.6 ms 3,900 s
SELECT ... FROM reports_daily ... 30 61,000 ms 1,830 s

Read it carefully, because the biggest problem is not the slowest query.

  • The menu query takes under 3 milliseconds, yet its total time is the largest of all: 8 million calls a dinner, about 750 a second. It's also the one caching (Module 3) should be absorbing. That many calls means the cache isn't doing its job, maybe because the TTL is too short.
  • The order history query at 14 ms smells like the (customer_id, created_at, id) index from Module 2 is missing or not being used. Module 2 showed how much that can matter.
  • The order status query takes under a millisecond. About 10,000 customers have their order screen open at the dinner peak, and each one's app polls it every 10 seconds: about 1,000 calls a second, almost 11 million a dinner. Each call is cheap, but nearly every one asks a question whose answer hasn't changed.
  • The daily report is run by the finance team's dashboard. Only 30 calls, but each takes a minute and reads millions of rows, right in the middle of dinner.

The query that hurts most is the one with the highest total time, not the highest time per call. A fast query run millions of times can cost more than a slow one run rarely.

A quick check on the scale: the first row alone is 23,500 seconds of database time over a 3-hour dinner. That's 23,500 / 10,800 ≈ 2.2 seconds of work every second, so more than two CPU cores do nothing all evening but build menus the cache should have served. The top four rows together keep about five of the database's 32 cores busy. The rest of its load comes from the long tail of queries below the top five, and from juggling hundreds of connections at once, which the next lesson deals with.

Four ways a database runs out

Like an app server, a database can run out of different things. Each looks different, and each has different fixes.

What runs out How it looks Typical cause
CPU Database CPU near 100% Too many queries, or queries scanning too many rows
Memory and disk Cache hit ratio drops, disk reads climb The data you read often (the working set) no longer fits in memory
Connections "Too many connections" errors, long waits for a connection Too many app processes each holding their own connections
Locks Queries waiting on other queries, CPU not maxed Many writes to the same rows at once

Memory and disk

Postgres keeps recently used data pages in memory. A read from memory takes microseconds. A read from SSD takes about 100 microseconds or more, and every query that misses waits for it. When the working set fits in memory, the cache hit ratio is above 99%. When the data outgrows memory, it drops, and latency follows.

For Tiffin: orders from the last week are read constantly, older ones rarely. While a week of orders fits in memory, everything is fast. Module 9 covers what to do when even the recent data gets too big.

Locks

Say the order transaction also runs UPDATE city_stats SET orders_today = orders_today + 1 WHERE city_id = 1. Now every order in the city needs the lock on that one row, and holds it until its transaction commits. If the order transaction takes 50 ms (it also writes the items and payment row, and waits for the commit to reach disk), only about 20 orders a second can get through, however many cores the database has. One hot row turns parallel work into a single-file queue. Database CPU looks fine, but queries pile up waiting. Module 12 returns to counters like this.

Reads and writes

One more number shapes everything in this module: how much of the database's work is reading, and how much is writing.

For Tiffin, counting queries at dinner:

reads:   ~6,200 queries/s  (menus, order status, history, restaurant lists)
writes:  ~1,500 queries/s  (orders, status changes, rider locations)
share of reads: 6,200 / 7,700 ≈ 80%

That's typical. Most apps read far more than they write. And it matters, because reads and writes scale very differently. Reads can be answered from a copy of the data, so you can add copies. Writes must all go to one place in a normal setup, so copies don't help them. The read replicas lesson builds on exactly this.

The order of fixes

There's a well-worn order to work through, cheapest and safest first:

  1. Fix the queries. Add missing indexes, stop scanning rows you don't need, kill N+1 query loops. Free, and often the biggest win. (Module 2)
  2. Cache what's read often. Menus and restaurant lists barely change. (Module 3)
  3. Pool connections. Stop hundreds of app processes each holding their own connections. (Next lesson)
  4. Move heavy work off the busy hours, or off the main database. That finance report doesn't need to run on the primary at 8pm.
  5. Scale up the database. More cores and memory. Simple, and it goes a long way. (Module 4 made the case)
  6. Add read replicas for the reads that are left. (This module)
  7. Archive, partition, and eventually shard. For data that has outgrown one machine. (Module 9)

Each step down the list costs more effort and adds more moving parts. Plenty of companies never get past step 6.

For Tiffin, the plan from that pg_stat_statements output is: index the order history query, fix the menu cache TTL, move the finance report to run at 4am, and look at whether order status needs to be polled from the database at all (Module 6 has a better answer). Then pool the connections, which is the next lesson.

Which query first?

Another app's top queries by total time are below. You have one afternoon. Which would you look at first, and what would you try?

Query Calls Mean Total
A: product page lookup by ID 40 M 0.3 ms 12,000 s
B: search with LIKE '%term%' 200 K 45 ms 9,000 s
C: nightly export 1 1,500,000 ms 1,500 s
Show one answer

A has the highest total, but at 0.3 ms per call it's already efficient. The fix isn't to make the query faster, it's to stop running it 40 million times: product pages are a classic caching target. That's probably the biggest win for an afternoon.

B is next. A LIKE '%term%' can't use a normal index, so each call scans the table. A trigram index, or a separate search engine (Module 9), would help, but that's more work.

C runs once, at night. Unless it overlaps with peak traffic, leave it.

Read the symptoms

During dinner, a database shows 35% CPU, a 99.8% cache hit ratio, and connections well under the limit. But order placement p99 is 3 seconds, and many queries are shown as "waiting for lock". What's going on?

Show one answer

It's not CPU, memory or connections. Many transactions are trying to update the same rows at once and waiting in line for each other. Look at which rows: something like a per-restaurant counter, a shared stock count, or a long transaction that holds a lock while it calls a slow external API. Shorten the transactions (never call a payment provider inside an open transaction), and avoid having every order update the same hot row.

What to remember

  1. Low app server CPU with high latency, and no gain from more servers, usually means the database is the limit.
  2. More app servers add demand on the database without adding capacity, and can make things worse.
  3. Ask the database what hurts. Sort queries by total time, not time per call.
  4. A database can run out of CPU, memory, connections or locks, and each needs a different fix.
  5. Most apps are mostly reads. Reads can be served from copies, writes can't.
  6. Fix in order: queries, caching, connection pooling, moving heavy work, scaling up, replicas, then partitioning.