Module 5 / Scaling the database

Module 5 · lesson 8 of 8 · Lab 19 min

Lab: Add replicas safely

Scale reads without showing customers stale orders.

In this lab you'll add read replicas to Tiffin for real: decide which reads move, how many replicas you need, how to stop customers seeing missing or stale orders, and how to roll it out without a bad Friday. For each step, decide first, then compare with one way of reasoning it through.

The brief

Tiffin's database team has done the cheap fixes from this module: queries are indexed, menus are cached in Redis, PgBouncer pools connections, and the primary has been scaled up to 64 cores. A synchronous standby in a second zone handles failover.

It's not enough for long. At Friday dinner, the primary sits at 62% CPU, and traffic grows about 8% a month. The goal for this project:

  • Get the primary under 40% CPU at dinner, with room to grow for a year.
  • No customer ever sees an order they just placed go missing, and order status never goes backwards.
  • Losing any one replica doesn't hurt customers.

Here's where the primary's CPU goes at peak, from pg_stat_statements and request tracing:

Workload Queries/s at peak Share of primary CPU
A. Menu reads (Redis cache misses) 900 8%
B. Restaurant list and search 1,800 14%
C. Reviews and ratings 600 4%
D. Order status (about 10,000 open order screens, each polling every 10 s) 1,000 4%
E. My orders and order details 400 6%
F. Kitchen tablet poll: 3,000 tablets every 5 s, two queries each 1,200 7%
G. Checkout reads: cart, prices, stock, promo 300 3%
H. All writes: orders, status changes, rider locations 1,500 14%
I. Finance dashboard a few per minute 2%
Total 62%

Step 1: Which reads can move?

Sort the workloads

For each read workload A to G and I, decide: replica, replica with extra care, or primary. Then estimate the primary's CPU afterwards.

Show one answer
Workload Decision Reasoning
A. Menus Replica A dish change seconds late is harmless. Most of it is cached anyway.
B. Restaurants and search Replica Nobody can tell a second of staleness.
C. Reviews Replica Same.
D. Order status Replica, with care Status changes are written by restaurants and riders, and being a second late is fine. But the customer polls a brand-new order straight after placing it. Needs read-your-writes handling (Step 3).
E. My orders Primary The customer's own data, read right after they write it. Small load.
F. Kitchen poll Replica, with care New orders written by customers arriving a fraction of a second late is fine, since the tablet only polls every 5 s anyway. But after the tablet accepts an order, it must see its own change.
G. Checkout Primary Prices, stock and promo counts must be current.
I. Finance Separate reporting replica Keep heavy queries away from customer replicas.

Primary afterwards: writes 14% + my orders 6% + checkout 3% + a little for fallbacks ≈ 24%. That leaves room for writes to grow for well over a year.

Moving to customer replicas: A + B + C + D + F = 8 + 14 + 4 + 4 + 7 = 37% of a primary's worth of CPU.

Notice the default. Workload E could technically go to a replica with read-your-writes tricks, but it's small, so keeping it on the primary buys simplicity cheaply. Move the big, easy loads first. Don't spend complexity on the small ones.

Step 2: How many replicas?

Size the replica pool

Replicas are the same size as the primary. Each one also spends about 2% CPU replaying writes (one core of 64, since Postgres replays in a single process). The reads you're moving are 37% of a primary's worth. Reads grow 8% a month. How many replicas, and why? Keep each replica under 50% even if one of them fails.

Show one answer

Two replicas: 37 / 2 + 2 = 20.5% each. If one fails, the other takes 37 + 2 = 39%. Under the line on day one, and over it after about three months of growth.

Three replicas: 37 / 3 + 2 = 14% each. If one fails, the other two take 18.5 + 2 = 20.5% each. Comfortable.

Growth: 8% a month doubles traffic in about 9 months (1.08^9 ≈ 2). By then reads are 74% of a primary's worth and replay work has doubled too, so three replicas run at about 29% each, and about 41% each if one fails. The one-failed case crosses 50% around month 11, so plan to add a fourth around month 10, rather than at the last minute.

Put one replica in each of three zones. Then losing a whole zone costs one replica, which the design already survives.

Plus one more for finance: a smaller, separate reporting replica. Its queries can take minutes, and you can let it fall behind during them (by allowing longer replay delays) without any customer noticing.

Step 3: Read-your-writes and monotonic reads

This is the step that decides whether the project succeeds or causes the "my order vanished" tickets from the replication lag lesson.

Design the consistency rules

Work out what to do for these three cases:

  1. A customer places an order, and their app immediately starts polling GET /orders/:id/status.
  2. A customer's app polls status every 10 seconds. With three replicas at different lag, status could appear to go backwards.
  3. A kitchen tablet taps "Accept" and then refreshes its order list.
Show one answer

1. New order, immediate poll. First, the app shows the order from the POST /orders response, so there's no empty screen. Then, in the status endpoint, if the replica says "not found", check the primary before answering. An order that doesn't exist yet on a replica is the only case where this fallback runs, so it adds very little primary load:

async function getOrderStatus(orderId, customerId) {
  const db = replicaFor(customerId);
  let row = await db.oneOrNone('SELECT status, updated_at FROM orders WHERE id = $1', [orderId]);
  if (!row) {
    // just placed: this replica may not have it yet
    row = await primary.oneOrNone('SELECT status, updated_at FROM orders WHERE id = $1', [orderId]);
  }
  return row;
}

2. Status going backwards. replicaFor(customerId) hashes the customer ID to pick one replica, so each customer always reads from the same one. A single replica only ever moves forward, so status can't go from "Out for delivery" back to "Preparing". If that replica is removed, the customer moves to another, and the rare small jump is acceptable.

3. Tablet after accepting. The tablet moves the order into the "accepted" list immediately, using the response to its own request. For its next reads, the server stores the primary's log position (LSN) after the accept and only serves that tablet from a replica once the replica has replayed that far. Otherwise it reads from the primary. Tablets accept a few orders a minute each, so this rarely touches the primary.

Case 1 as a request flow, with the fallback that saves the customer from an empty screen:

%% caption: A brand-new order isn't on the replica yet, so the status read falls back to the primary once.
sequenceDiagram
  autonumber
  participant C as Customer app
  participant S as App server
  participant R as Pinned replica
  participant P as Primary
  C->>S: POST /orders
  S->>P: insert the order
  S-->>C: the order, shown at once
  C->>S: GET /orders/:id/status
  S->>R: read the status
  R-->>S: not found yet
  S->>P: read the status
  P-->>S: placed
  S-->>C: placed
A brand-new order isn't on the replica yet, so the status read falls back to the primary once.

Write these rules down where every engineer will see them. The most common way this design breaks isn't the rules being wrong. It's a new endpoint added six months later that reads from a replica without anyone thinking about lag. That's why the default for new code is the primary.

Step 4: Guardrails for lag

When lag spikes

On a Wednesday afternoon, a data backfill makes one replica fall 40 seconds behind. What should happen automatically, and what if all three replicas fall behind at once?

Show one answer

One replica behind: its health check includes lag. Above 5 seconds it's marked unhealthy and removed from rotation. Its customers are re-hashed to the other two, which have plenty of room. When it catches up, it comes back. An alert fires when any replica passes 1 second for more than a minute.

All replicas behind: don't blindly send everything to the primary. 24% + 37% = 61% is survivable today, but not in six months, and certainly not at 8pm. Instead: keep serving menus, search and reviews from replicas and cache (stale by a minute is fine for those), and move only the consistency-sensitive reads (order status, kitchen polls) to the primary.

Prevention: run backfills in small batches with pauses, watching lag, and never during dinner.

Step 5: Roll it out

Plan the rollout

You could switch all five workloads to replicas in one deploy. Should you? If not, in what order, and what do you watch?

Show one answer

No. Move one workload at a time, each behind a feature flag, starting with the least risky:

  1. Reviews (C), then restaurant list and search (B): no consistency risk, and B is a big chunk of load.
  2. Menus (A): low risk.
  3. Kitchen poll (F): needs the LSN check working. Start with 5% of restaurants.
  4. Order status (D): the riskiest. Start with 5% of customers for a few days.

Watch: replica lag and CPU, primary CPU, the rate of "order not found" responses, how often the primary fallback runs, and support tickets mentioning missing or wrong orders. Leave a week between the last two steps, including a Friday dinner.

Step 6: Failover and backups

Adding replicas changes two things you already had working.

What else needs updating?

The synchronous standby still handles failover. What happens to the three read replicas and the reporting replica when the primary fails over? And should backups change?

Show one answer

Failover: after the standby is promoted, every replica must start following the new primary. The failover tool (such as Patroni) should do this automatically, and the monthly failover drill should now check that all four replicas reconnect and that their lag returns to normal. Keep the standby free of read traffic, so it's always ready and never overloaded at the moment it gets promoted.

Backups: still essential, since replicas copy mistakes. One improvement: take base backups from a replica rather than the primary, so the large file copy doesn't compete with dinner traffic. Tools like pgBackRest support this. WAL archiving continues as before. The weekly restore test stays.

The final design

%% caption: Tiffin after the project. Customer reads spread over three replicas, one per zone, each about 14% CPU at dinner.
flowchart TD
  A["App servers"] --> PB["PgBouncer primary"] --> P[("Primary<br/>writes, checkout, my orders, ~24%")]:::hot
  A -- "customer reads" --> RS[("Replicas 1, 2, 3<br/>zones A, B and C")]:::store
  P -. "replicates" .-> RS
  P -.-> SB[("Sync standby<br/>zone B, failover only")]:::store
  P -.-> RR[("Reporting replica<br/>finance only")]:::store
Tiffin after the project. Customer reads spread over three replicas, one per zone, each about 14% CPU at dinner.

How the app chooses:

default for new code:          primary
menus, search, reviews:        replicas, any of the three
order status, kitchen polls:   the customer's or restaurant's pinned replica,
                               with primary fallback for new orders and LSN check after writes
checkout, my orders, writes:   primary
finance:                       reporting replica
replica lag > 5 s:             removed from rotation

And the results: the primary drops from 62% to about 24% at dinner, the replicas run at about 14% each, any one replica or a whole zone can fail without customers noticing, and nobody's order vanishes.

Build it on AWS

Tiffin's database is RDS for PostgreSQL, and the "synchronous standby in a second zone" is simply RDS Multi-AZ, which Tiffin has had since launch (see the first version lab). Read replicas are a few clicks on top. One change since the dinner rush lab: when the team scaled up to 64 cores, they also moved from the general-purpose m7g family to the memory-optimised r7g, which has twice the memory per vCPU, so more of the working set stays in cache. The interesting parts are the ones the console doesn't tell you about: what this costs, which metric lies at 4am, and why the managed connection pooler can't pool replicas. Prices are Mumbai (ap-south-1) on-demand list prices as of late 2026; check the AWS pricing calculator before quoting them.

The architecture

%% caption: The replica project on RDS. Writes through RDS Proxy; pinned customer reads through PgBouncer to three replicas.
flowchart TD
  T["ECS tasks"] -- "writes, checkout" --> RP["RDS Proxy"] --> P[("Primary, Multi-AZ<br/>db.r7g.16xlarge")]:::hot
  T -- "pinned customer reads" --> PB["PgBouncer on ECS"] --> RS[("3 read replicas<br/>one per zone")]:::store
  P -. "asynchronous" .-> RS
  P -.-> RR[("Reporting replica<br/>db.r7g.4xlarge")]:::store
  HB["Heartbeat writer"] -.-> P
The replica project on RDS. Writes through RDS Proxy; pinned customer reads through PgBouncer to three replicas.
Generic box On AWS The setting that matters
Primary plus synchronous standby RDS for PostgreSQL Multi-AZ, db.r7g.16xlarge (64 vCPU, 512 GiB) The standby can't serve reads. It only waits to take over
Replicas 1 to 3 RDS read replicas of the same class, one per Availability Zone in Mumbai Each has its own endpoint; RDS doesn't balance across them or add more for you
Reporting replica A smaller read replica, db.r7g.4xlarge Allowed to fall behind during long finance queries
PgBouncer RDS Proxy for the primary, PgBouncer on ECS for the replicas See Argument 3
Lag check A heartbeat row written every second, compared on each replica See Argument 2

Argument 1: what three full copies cost, and the other shapes

Replicas are billed as ordinary database instances, while the replication traffic itself is free within a Region:

Primary, Multi-AZ, db.r7g.16xlarge:   $17.408/hour  = ~$12,700 a month
3 replicas, same class:          3 x $8.704/hour    = ~$19,100
Reporting replica, r7g.4xlarge:       $2.176/hour   = ~$1,600
Storage, 800 GB gp3: Multi-AZ primary plus 4 replicas = ~$630

More than half the database bill is now replicas, because RDS read scaling means copying the whole database onto more machines. AWS sells two other shapes, and both are worth knowing even though this project doesn't pick them:

  • A Multi-AZ DB cluster has two readable standbys behind a reader endpoint. That gives fewer readers than the three this lab needs, and the readers are also the failover targets, which is exactly what Step 6 chose to avoid ("keep the standby free of read traffic").
  • Aurora PostgreSQL keeps one shared storage volume under all its replicas, so a replica doesn't need its own full copy on its own disk, and a reader endpoint spreads connections. It changes cost and failover more than any setting here. The course moves Tiffin to Aurora in Module 9, when storage growth forces the question. For this project, RDS replicas are a configuration change, not a migration, and that's the right size of risk.

Argument 2: the lag guardrail that fires at 4am

Step 4 removes a replica from rotation when its lag passes 5 seconds. The obvious way to measure lag on RDS is the ReplicaLag metric in CloudWatch. It has a trap that AWS documents: for PostgreSQL, the value is "now minus the time of the last replayed commit". So when the primary commits nothing, which can happen at 4am, the reported lag climbs for up to five minutes until the next log segment switches, even though the replica is fully caught up. A rule of "remove above 5 seconds" built on that metric evicts every replica overnight and pages someone about it.

The fix is to give replicas something to replay. Run a tiny heartbeat job that updates one row on the primary every second with the current time. Each replica's lag is then "now minus the heartbeat it can see", which is honest whether Tiffin is busy or asleep. For read-your-writes, the LSN check in Step 3 still uses PostgreSQL's own functions (pg_last_wal_replay_lsn() on the replica), which work unchanged on RDS.

There's no reader endpoint for plain RDS replicas either, so replicaFor(customerId) hashes over a list of replica endpoints kept in configuration. Removing a lagging replica means taking it out of that list (or out of PgBouncer's pool), and the customers pinned to it re-hash to the others, exactly as Step 4 planned.

Argument 3: who pools the replica connections?

RDS Proxy is the managed answer to PgBouncer, and it's good at the primary's problem: it holds a small pool of database connections for hundreds of app processes, and it handles failover without each task reconnecting on its own. In Mumbai it costs $0.017 per vCPU-hour of the database it fronts, about $800 a month for a 64-vCPU primary.

But it doesn't cover this lab's replicas. AWS documents that for RDS instances in a replication setup, a proxy can attach only to the writer, not to a read replica. Reader endpoints exist only for Aurora and Multi-AZ DB clusters. So the split is:

  • Primary: RDS Proxy. Checkout and writes are where connection storms and failovers hurt most, and someone else runs it.
  • Replicas: PgBouncer, which the team already runs, as a small ECS service with one pool per replica. It costs a few tens of dollars a month in tasks, plus some care when a replica is removed from the list.

One more number: tasks reading from replicas in other zones pay for cross-zone data transfer, $0.01 per GB each way. The moved reads are 5,500 a second at the dinner peak; averaged over the day, say about 2,200 a second. At about 2 KB per response that's roughly 11 TB a month, two thirds of it crossing zones, which is about $150 a month. That's not worth giving up the customer-pinned design for zone affinity.

Argument 4: failover drills and backups, re-read for RDS

Two tools from Step 6 go away. Patroni isn't needed, because RDS runs the failover: it promotes the standby and moves the database's DNS name, typically within 60 to 120 seconds. The monthly drill becomes "reboot with failover" on the primary, at a quiet hour, then checking that every replica's heartbeat lag returns to normal and that the app reconnected through RDS Proxy.

pgBackRest isn't needed either. RDS takes the automated backups, and Multi-AZ exists partly so that backups don't cause latency spikes on the primary. Point-in-time restore works on the primary, not on read replicas, so the weekly restore test restores the primary to a new instance, as before. One quota to watch: Mumbai's default limit is 20 RDS instances per account. This project uses five, but staging copies and restore tests count too.

What it costs

Line Monthly, roughly
Primary, Multi-AZ db.r7g.16xlarge ~$12,700
Three read replicas, same class ~$19,100
Reporting replica db.r7g.4xlarge ~$1,600
Storage, 800 GB on five instances ~$630
RDS Proxy for the primary ~$800
Cross-zone reads ~$150
Total ~$35,000

At about 200,000 orders a day, that's roughly 0.6 cents per order. Replicas are the biggest line, which is the subject of the exercise.

The same design on GCP and Azure

Role AWS Google Cloud Azure
Managed PostgreSQL with a standby RDS Multi-AZ Cloud SQL with high availability Azure Database for PostgreSQL, zone-redundant
Read replicas RDS read replicas Cloud SQL read replicas Read replicas
Shared-storage PostgreSQL Aurora PostgreSQL AlloyDB No direct equivalent

The traps carry over: check how each platform reports replica lag on an idle database, and whether its managed pooler can reach replicas.

Can the replicas be cheaper?

The three replicas are each db.r7g.16xlarge, matching the primary, and they cost about $19,100 a month. Each runs at about 14% CPU at dinner. Propose a cheaper replica setup that still keeps every replica under 50% CPU if one fails, and say what you'd watch.

Show one answer

Use four db.r7g.8xlarge replicas (32 vCPU each) instead of three 16xlarge ones. Four at $4.352 an hour cost about $12,700 a month, saving about $6,400.

Check the CPU with the lab's own numbers. The moved reads are 37% of a 64-core primary, about 24 cores, and replay costs about 1 core per replica:

Four replicas:      24 / 4 + 1 = 7 cores of 32 = ~22% each
One fails:          24 / 3 + 1 = 9 cores of 32 = ~28% each

Both are under 50%, and losing one replica now costs a quarter of the read capacity instead of a third.

What to watch: memory. Each replica drops from 512 GiB to 256 GiB, so if the hot working set doesn't fit, cache hit rates fall and disk reads rise. Check the buffer cache hit ratio during a rollout on one replica first. And growth: at 8% a month, reads and replay work double in about nine months, which would push the one-failed case to about 56%. It crosses 50% around month seven, so plan the fifth replica then. RDS allows up to 15 replicas per primary, so there's plenty of room.

What to remember

  1. Start from a breakdown of the primary's load by workload, so you know what moving each one buys.
  2. Move big, low-risk reads first. Keep small or consistency-sensitive reads on the primary when it's cheap to.
  3. Size replicas for N+1 and growth, and spread them across zones.
  4. Design read-your-writes and monotonic reads per workload: use write responses, primary fallbacks, pinned replicas and LSN checks.
  5. Remove lagging replicas automatically, and don't let a lag spike dump all reads on the primary.
  6. Roll out one workload at a time behind flags, and update failover drills and backups for the new replicas.
  7. On RDS, replicas are full copies billed as instances, and here they're over half the database bill. Size them deliberately: several smaller replicas can be cheaper and fail more gracefully than a few huge ones.
  8. Know what your managed service measures and covers. RDS PostgreSQL's ReplicaLag climbs on an idle primary, so use a heartbeat. RDS Proxy doesn't pool plain read replicas.