Part III · Chapter 4 · The Database · lesson 6
One table is 400GB and everything that touches it is slow
15 min
Builds on The events table nobody deletes from
Part III · Chapter 4 · The Database · lesson 6
15 min
Builds on The events table nobody deletes from
The events table is 400GB. Inserts are fine. Every query that touches it is
somewhere between painful and timing out, the nightly aggregate takes four
hours, and VACUUM never finishes inside its window. Three people have said
the word sharding this week and nobody has said what it would be sharded by.
Partition, shard, or archive. Three answers with three very different costs, and one question picks between them.
Predict
Archive first, because it is the only one that removes work. If queries only ever touch the last ninety days and the table holds four years, then roughly 94% of the rows are being carried for nothing: indexed, vacuumed, backed up, and scanned whenever a query misses its index. Move them somewhere cheap, keep a path to read them for the rare case, and the remaining table may not need splitting at all. This is the answer that gets skipped because it is not architecture.
Partition when every query carries a range. Postgres declarative
partitioning by range on a timestamp lets the planner prune: a query with
created_at >= now() - interval '7 days' reads one or two partitions and
ignores the rest. The nightly aggregate becomes per-partition work. VACUUM
runs per partition. Dropping the oldest month becomes a DETACH and a DROP
rather than a DELETE of a hundred million rows, which means archiving gets
cheaper too. One database, one connection string, no application change.
The constraint to know before you commit: in Postgres the partition key must
be part of every unique constraint and primary key on the table. If your
events table has a unique constraint on an external id, that constraint
either gains the partition column — weakening it — or goes away. This is the
single most common reason a partitioning plan is discovered to be impossible
in week two. MySQL has the same rule for its native partitioning, where every
unique key must include all columns of the partitioning expression.
Shard when every query carries one entity id, and you have run out of machine. Sharding puts pieces of the table in different databases, so it is the only one of the three that raises your write ceiling and the only one that makes a cross-piece query a distributed join you have to write yourself. It is also the one that changes your failure model, which is what the model below is about. Citus does this for Postgres, Vitess for MySQL, and both are real products with real operational weight; doing it by hand in the application is a decision to maintain a database.
Splitting a table across machines does not only change performance. It changes how much of your user base one dead machine takes with it, and it changes it in a way people consistently guess wrong.
Blast radius
Hypothetical model, not a forecast
The share of users one failed node touches, given shard count and replication, next to the share a correlated failure touches. Rows step through shard counts by doubling, so period is the doubling index and the row's shards key carries the real count.
It assumes
It ignores
Raise the shard count until one failed node touches no more than 5% of users, then look at the flat line. Find the shard count that meets the target and say what the correlated fault still reaches.
Total copies of each shard, so 1 means a single unreplicated node.
| Shards | Share one failed node touches | Share a correlated fault touches |
|---|---|---|
| 1 | 100% | 100% |
| 2 | 50% | 100% |
| 4 | 25% | 100% |
| 8 | 12.5% | 100% |
| 16 | 6.25% | 100% |
| 32 | 3.13% | 100% |
| 64 | 1.56% | 100% |
| 128 | 0.78% | 100% |
| 256 | 0.39% | 100% |
| 512 | 0.2% | 100% |
| 1024 | 0.1% | 100% |
Not there yet — keep moving the controls.
Two readings.
Sharding is a blast-radius argument as much as a capacity one. At one shard, any failure that outlives your replicas is everybody. At thirty-two, it is about three percent of users, and the difference in how an incident feels is larger than the difference in the number. That is a real reason to shard, and it is a different reason from running out of write capacity.
It buys you nothing against the failures you actually have. The flat line is the point. A bad schema migration, a bad config push, a bad deploy, an expired certificate, a full disk on a shared dependency — none of them respect shard boundaries, and all of them are more common than independent node death. Replication divides an independent failure and does nothing to a correlated one.
Check
Everything above assumes the load spreads roughly as evenly as the data. Once one key draws more traffic than one node can serve, no amount of splitting helps, because the smallest unit you can split to is one key and that key is already on one machine. The model says this in its first assumption and the real world says it with a single tenant who is a tenth of your revenue, or one viral piece of content, or a batch job that reads one customer's history in a loop.
The crossing is specific: when the busiest single value of your partition key needs more read or write throughput than one replica set can provide. Past that point the answers are different in kind — a cache in front of that key, a separate topology for that one tenant, splitting the key itself into sub-buckets with a composite key, or changing the access pattern so the hot key is not read on the critical path. All four are more work than sharding and none of them is sharding.
Which is why I would partition before sharding in almost every case, and measure the distribution of the partition key before either.
"Move the old rows somewhere cheap" hides three decisions.
Where. A second table in the same database buys you nothing on backup size or memory pressure. Object storage as Parquet, queried by something like DuckDB or a warehouse, is the cheap answer and makes the old data a different system with a different query language. A separate database keeps SQL and keeps most of the cost.
How the read path finds it. If a query may need archived rows, something has to decide where to look. A union view across live and archive is the honest version, and every query that reaches the archive half pays object-storage latency for it. A feature flag saying the UI only shows ninety days is faster and is a product decision pretending to be a technical one. Say which you are making.
How you get the rows out without a long-running delete. This is the real
reason partitioning and archiving belong in the same conversation: a
DELETE FROM events WHERE created_at < … on a hundred million rows generates
an enormous amount of write-ahead log, bloats the table, and leaves vacuum
work behind. ALTER TABLE ... DETACH PARTITION followed by a DROP TABLE is
a metadata operation. If you expect ever to archive, partition by the column
you will archive on, even if the query pattern does not demand it.