Cruxly

Cruxly › System design › Databases: Choosing One

Scaling Postgres and MySQL before you leave them

a ladder of cheaper, reversible steps, climbed in order, each with a signal

Open the interactive lesson

"My Postgres is at 80% CPU" is a symptom, not a reason to change databases. Between that symptom and a new system sit about nine cheaper steps: fix queries, buy a bigger machine, pool connections, cache, add replicas, partition tables, archive and split analytics, split by function, shard. Each has a signal that says climb and a cost you can name. Real companies (OpenAI, Notion, Figma, and Shopify for sharding) followed much of this path and went a long way on relational databases.

The ladder: cheapest and most reversible first

The choosing lesson ended with a rule: stay on a relational primary until a measured limit says otherwise. This lesson is everything between "one primary is fine" and "we need a different database". Climb in this order, and stop as soon as the signal clears.

#RungSignal that says climbWhat it costs
1Fix queries, indexes, bloatA few statements dominate CPU or IO; sequential scans; dead rows piling upEngineer days. Reversible. No new parts
2Bigger machineQueries are healthy but headroom is goneMoney, a failover, a ceiling
3Connection poolingRefused connections, thousands of idle ones, slow connectsOne more hop, no session state
4Cache in frontThe same rows are read over and overStaleness, invalidation, miss storms
5Read replicasReads dominate and the primary is still hotLag, read routing, replay conflicts, WAL fan-out
6Partition tablesOne huge table, mostly time-ordered, with retention painPartition jobs. Still one server
7Archive, split analyticsOld data and long scans bloat the OLTP boxA pipeline to run
8Functional splitA table family dominates load and joins littleLose joins, foreign keys, atomic commits across the split
9ShardWrites, vacuum or IOPS outgrow the largest boxCross-shard queries, resharding, app changes
10Another systemA shape relational does badlyMigration, new skills, new failure modes

The order follows two things: cost, and how easily you can undo it. Rungs 1 to 5 are mostly easy to back out of. Rung 9 changes how every query is routed, and rung 10 is a migration. Skipping to either is the classic interview mistake.

Rungs are not strictly sequential. The signal picks the rung: refused connections go to rung 3 whatever the CPU says, and a 3 TB events table goes to rung 6. Rung 1 never really ends. Teams also swap 4 and 5: replicas are simpler to run than a cache with invalidation, and many add them first.

:::key Reads have many cheap answers. Writes have few. Rungs 3, 4 and 5 relieve reads and connections. For writes, only fixing queries (fewer, cheaper writes), a bigger box, a functional split, sharding or moving a workload give the primary relief. Partitioning and archiving make vacuum and retention cheaper but do not cut write volume. That is why writes decide when you reach the top of the ladder. :::

Rung 1: fix the queries, the indexes and the bloat

Start where the load comes from. In Postgres, pg_stat_statements ranks statements; in MySQL use the slow query log and performance_schema. Rank by total time (calls x average), not the slowest single call. For example, a 2 ms query run 50,000 times a second (assumed numbers) can cost more than a 3 second report.

Then read the plan with EXPLAIN (ANALYZE, BUFFERS). What you usually find:

FindingFixWatch out for
Sequential scan on a big tableAdd the index that matches the filter and sortBuild with CREATE INDEX CONCURRENTLY; OpenAI allows concurrent index builds but limits other schema changes
N+1: one query per row of a listBatch with one IN query or a joinORMs hide it; log the SQL a page really emits
Many-way joinsSplit the query and join in the appOpenAI traced past SEVs to one query joining 12 tables, and says many such queries come from ORMs
Too many indexesDrop unused onesEvery index is extra work on every write
Retry stormsLonger backoff, query-level rate limitsOpenAI can block specific query digests to shed load

Bloat is the quiet one. Postgres MVCC writes a new copy of the row on update, so heavy updates leave dead tuples that queries must skip and autovacuum must clean. OpenAI names this write and read amplification as the weak spot of its setup. A transaction left open and idle holds vacuum back, so set idle_in_transaction_session_timeout. If vacuum cannot keep up on your largest tables, that points at later rungs, not at tuning forever (Notion sharded for exactly this reason).

:::key Stop condition Rung 1 is done when the top few statements are no longer most of the load and the plans use indexes. If CPU is still high with healthy queries, you are really out of capacity and can climb. :::

Illustrative example (assumed numbers): one statement is 70% of total time because a filter on customer_id scans 40 million rows. An index drops CPU from 80% to roughly 30%. That is a day of work, and it avoided a migration.

Rungs 2 and 3: a bigger machine, and the connection wall

A bigger machine is the honest bridge. Figma's April 2023 post, describing its 2020 fixes, lists it first: its single RDS Postgres was at upwards of 65% CPU at peak, and it moved from r5.12xlarge to r5.24xlarge to buy about a year, together with replicas, new databases for new use cases, and PgBouncer. Do it when queries are already healthy and you need time.

Its limits:

Connections are the first wall. Postgres starts one process per connection, and the documented default max_connections is typically 100. Forty pods with a pool of 20 each is already 800 connections. OpenAI notes a 5,000 connection maximum on its Azure instance and had incidents from connection storms. The earlier operations lesson explains pooling; here are the scale facts:

Transaction pooling breaks session features. PgBouncer's docs list SET/RESET, LISTEN, PREPARE/DEALLOCATE, WITH HOLD cursors, and session-level advisory locks (among others) as not working in transaction mode. Protocol-level prepared statements work only if max_prepared_statements is non-zero.

Rungs 4 and 5: a cache, then read replicas

A cache in front removes repeat reads entirely, which beats making them cheaper. The cost is staleness, invalidation, and one nasty failure. OpenAI describes the pattern behind several SEVs: a caching-layer failure causes widespread misses, the misses hit Postgres, CPU saturates, requests time out, and retries amplify the load. Their fix is a lock or lease on a missed key: one request fetches from Postgres and refills the cache, and the rest wait. Pair it with rate limits and with retry intervals that are not too short.

Read replicas are the next rung when reads dominate. They work: OpenAI runs one primary with nearly 50 replicas across regions for millions of queries per second (January 2026 post). What breaks as you add them:

What breaksWhyWhat to do
Stale reads (lag)Replication is asynchronousKeep reads inside a write transaction on the primary (OpenAI does). Route a user's reads to the primary for a short window after their write, or until the replica has replayed their write. The earlier consistency lessons cover the guarantees
Cancelled long queriesWAL replay needs to remove row versions a standby query still seesPostgres docs: conflicting queries are cancelled after max_standby_streaming_delay; hot_standby_feedback avoids those cleanup conflicts (not every kind of conflict) but delays cleanup on the primary and can bloat it. Send long reports elsewhere
Writes do not scaleOne primary still takes every writeReplicas only add read capacity
WAL fan-outThe primary ships WAL to every replica, using network and CPUOpenAI says lag gets higher and less stable with more replicas, and is testing cascading replication (relays) for 100+ replicas, at the price of harder failover
A replica is not a backupA bad DELETE replicatesUse point-in-time recovery

Figma found that not all reads could move to replicas because parts of its application were sensitive to lag. Plan for that: replicas take the reads that tolerate staleness, and the rest stay on the primary.

:::key Replicas help reads, not writes If the primary's write load is the problem, replicas leave it untouched, and each one adds WAL work. That points to rungs 6 to 9. :::

Rungs 6 and 7: partition the table, archive the old data

Partitioning splits one logical table into many physical tables that Postgres treats as one (range, list or hash). Use it on big tables with a natural key, usually time: events, logs, orders by month.

What you get, from the Postgres docs:

What you pay:

Archive and separate analytics (rung 7). Keep the hot window your queries actually need, usually weeks or months, and move the rest to cheaper storage: object storage or a warehouse. Detach a partition, copy it out, then drop it. Feed dashboards and long scans from a copy made through change data capture, not from the primary. Uber's 2016 post noted that long reads on Postgres replicas can lag or be killed, so a report replica alone is not enough.

Time-series workloads show the trap: one row per reading in an unpartitioned table, with retention done by DELETE. Partition by time and drop partitions instead.

Rung 8: split by function and move analytics out

A functional split (Figma calls it vertical partitioning) moves whole groups of tables to their own database. It gives a heavy table family its own machine without changing how any single table is stored. By the end of 2022, Figma had caching, read replicas and a dozen vertically partitioned databases, with families such as files and organizations on their own. Its later post calls this a relatively easy and very impactful lever.

How Figma chose tables (April 2023 post):

  1. Impact. Move tables that carry a lot of load. They measured average active sessions per query, sampling pg_stat_activity every 10 ms and grouping by table.
  2. Isolation. Move tables that are rarely joined with others. Ruby and ActiveRecord hid which tables a query touched, so they added runtime validators that logged queries and transactions into their warehouse.

What it costs. Once tables live in different databases you lose atomic transactions between them, foreign key checks and joins. The app must join in code and check references itself. A migration without downtime needs every app instance to switch routing at the right moment; Figma's goals were under a minute of availability impact, automation, and the ability to undo.

Limit. The smallest unit is a table. When one table outgrows a machine (several TB, billions of rows, vacuum trouble, near the IOPS cap), a functional split cannot help.

Isolate workloads, not only tables. OpenAI splits requests into low and high priority and sends them to separate instances, and does the same per product, so a new feature's slow query cannot starve a critical one. That is the same idea at the instance level.

Analytics belongs elsewhere. Move reports to a warehouse, fed by change data capture. Reporting on the primary competes for IO and locks, and a replica has the replay-conflict limit from the previous rung.

Rung 9: shard, and know the bill before you start

Shard when a signal shows that no single machine will do, not before:

CompanySignal that forced sharding
Notion, 2021VACUUM stalled consistently, and transaction ID wraparound (Postgres stops accepting writes to protect data) became a real risk
Figma, 2022Tables of several TB and billions of rows hurt during vacuum; the busiest tables were growing toward the RDS IOPS maximum
Notion, 2023Shards above 90% CPU at peak, near their IOPS limit, with PgBouncer connection limits

Choose the route.

RouteUse whenCost
App-level shardingYou want control over placement. Notion chose it over Citus and Vitess because the clustering logic there is opaqueYou build routing, resharding and tooling
Vitess (MySQL)MySQL, many shards, you want a routing layer. Shopify's post describes MySQL shards ("pods") keyed by shop id with its own routing table, not VitessCross-shard writes: Vitess offers single, multi (best effort, no atomicity) and TwoPC modes; TwoPC is atomic but not fully isolated, so cross-shard reads can see partial commits
Citus (Postgres)Postgres, multi-tenant or real-time analytics. You pick a distribution column; tables sharing it are co-locatedFilter queries by tenant id; make small shared tables reference tables
Your own subsetFigma did this: colocation so most queries stay on one shard, no atomic cross-shard transactionsNarrower SQL than a real database

The shard key decides everything. Notion keyed by workspace id and sharded every table reachable from block by foreign key, so a workspace's rows live together and most joins and transactions stay local. Shopify keys by shop id. For B2B SaaS, tenant id is the default. The earlier lesson on partitioning and sharding covers cardinality, skew and hot keys.

What you lose. Cross-shard joins, foreign keys and atomic commits (or you pay for TwoPC). Reports that span tenants fan out to every shard. Unique ids need a scheme.

Resharding is a feature you must build. Notion used logical shards (15 schemas per database, on 32 databases) and in 2023 moved from 32 to 96 machines by dividing those schemas, 15 per host down to 5. Shopify balances shards online: before rebalancing, its least and most used shards differed by almost 4x, and a simulation of planned shop moves brought that closer to 2x. Pick many logical shards on few hosts so growth is a move, not a rehash.

OpenAI's counterpoint. It left Postgres unsharded because sharding existing workloads would mean changes to hundreds of endpoints, taking months or years. It no longer allows new tables on the main cluster, defaults new workloads to sharded systems such as Azure Cosmos DB, and has migrated its shardable write-heavy workloads there. It does not rule out sharding Postgres later.

Rung 10 and the real ladders, with dates

Public stories show the ladder in use. Read them with their dates, because the tools changed.

Who, whenRungs they usedWhat pushed them up
Figma, 2020 to 2022Bigger instance (r5.12xlarge to r5.24xlarge), replicas, PgBouncer, new databases for new features, then about a dozen vertical partitions, then horizontal sharding (started late 2022; first table live September 2023)65%+ CPU at peak in 2020; later multi-TB tables and the IOPS ceiling. The database stack grew almost 100x since 2020. Their 2023 post judged NoSQL, Vitess and Postgres-compatible NewSQL too complex or risky a migration; their 2024 post lists CockroachDB, TiDB, Spanner and Vitess as evaluated and says NoSQL did not fit their relational model
Notion, 2021 and 2023Monolith on RDS, app-level sharding by workspace id, then 32 to 96 machinesStalled vacuum and TXID risk; later CPU above 90%
OpenAI, January 2026 postOne Azure PostgreSQL primary with a hot standby, nearly 50 replicas, PgBouncer, caching with locks, rate limits, workload isolation, query fixes. Load grew over 10x in the year before the postShardable write-heavy work went to sharded systems such as Azure Cosmos DB; new tables on the main cluster are no longer allowed
ShopifyMySQL shards ("pods") keyed by shop id, balanced onlinePer-shop data and the need to move shops between shards

OpenAI reports low double-digit millisecond p99 latency, five-nines availability, and one SEV-0 Postgres incident in 12 months. Its SEVs follow a pattern: an upstream spike (a cache failure, a join storm, a write storm) pushes Postgres past its limit, and retries make it worse.

Uber's 2016 post is version-dated. Uber moved from Postgres to MySQL, citing write amplification from indexes pointing at physical row locations, physical WAL replication cost across data centers, replicas that lag or cancel long reads, process-per-connection limits, and painful major upgrades. The analysis was based mainly on Postgres 9.2 (released in 2012), and the post itself is now about ten years old. This lesson did not verify which of those points later versions fixed, so check current versions before you quote it. Use it as a list of weaknesses to measure, not as proof that Postgres cannot scale.

Rung 10 is real. Leave when the signal names something relational does badly: sustained writes past what one box (or a sharded fleet you can run) can take, multi-region active-active, huge scans better served by columnar storage, search relevance, or traversals. Notice that even OpenAI took this step partially: one workload class moved, the rest stayed. The earlier lessons on choosing a database and on distributed SQL cover how to decide.

The interview answer, and wrong statements to avoid

Question: "Your Postgres is at 80% CPU. What do you do?" Say it in this order.

  1. Ask what the 80% is. Peak or all day? Primary or replica? Reads or writes? How fast is it growing? Figma acted at 65% peak because latency gets unpredictable near the limit, so the answer depends on the trend.
  2. Find the cause. Rank statements by total time, read the plans, check connection counts, idle-in-transaction sessions, dead tuples and vacuum, and whether the time is query work or waiting on IO.
  3. Apply the cheap reversible fixes. Fix the top queries and indexes, remove N+1 patterns, set timeouts, add a pooler if connections are the issue, cache the hottest reads with a lock on misses.
  4. Buy time and offload. Move tolerant reads to replicas and keep read-after-write on the primary. Resize if needed, as a bridge with a date.
  5. Plan the structural step from the signal. Big time-ordered table: partition and archive. One table family dominates: split it out. Writes, vacuum or IOPS beyond the biggest instance: shard by tenant, and say what you give up. Only a shape relational does badly justifies a different system.

Add the guard rails: rate limits and load shedding for surges, avoid schema changes that rewrite a big table, and a number that would make you revisit (for example, sustained CPU above 80% after rungs 1 to 5, or vacuum falling behind).

Common wrong statements

Wrong statementWhy it is wrongSay instead
"Postgres or MySQL can't scale, so use NoSQL"OpenAI serves millions of read queries per second from one primary plus nearly 50 replicas; Shopify runs sharded MySQL"Reads scale with replicas and cache. Writes are the limit, and I'd measure it first"
"Add read replicas to fix a write bottleneck"Replicas get every write and add WAL work to the primary"Replicas for reads; for writes I'd fix queries, then partition, split or shard"
"Partitioning is sharding"Partitions share one server's CPU, IOPS and connections"Partitioning shrinks tables and makes retention cheap; it doesn't add machines"
"Scale up and we're done"A ceiling exists, and vacuum degrades before hardware limits (Notion)"Resize buys time; I'd pair it with the structural plan"
"More connections fix the errors"A process per connection; the server slows down"Pool in transaction mode and cap pool sizes"
"A cache fixes everything"A cold cache or outage becomes a miss storm"Cache with a per-key lock and rate limits"
"Shard now, to be safe"It costs joins, transactions and a lot of code (OpenAI estimates months to years for its workloads)"Shard when writes, vacuum or IOPS outgrow the biggest box"
"Uber left Postgres, so should we"A 2016 analysis on 9.2"Which of those problems do we measure on our version?"

How to say it in the interview

"Before I change anything I'd find out what the 80% is: which queries, reads or writes, and whether it's the peak or all day. Then I climb the cheapest reversible step that matches the signal: fix the top queries, pool connections, cache hot reads, add replicas. A bigger machine buys time. Partitioning suits big time-ordered tables. A functional split and sharding come when writes, vacuum or IOPS outgrow the biggest box, and I'd say what each costs."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons