Nguyen Le Phong

软件架构基础第 10 篇,共 11 篇

Scaling the Database: Indexes, Read Replicas, Caching, and Sharding — In That Order

The database is almost always the first thing to buckle under growth — and the first thing engineers over-engineer in a panic. A no-hype ladder for scaling the data layer: why you measure and add an index before touching hardware, how read replicas exploit the read/write asymmetry (and the replication-lag trap they bring), where caching helps and why invalidation is the hard part, and when you finally reach for partitioning and sharding — the one decision that is genuinely hard to undo.

App servers are the easy part of scaling. They hold no state, so when traffic doubles you clone them behind a load balancer and walk away. The database is the hard part, because it holds the state — the one thing you cannot simply duplicate and forget. That is why, in almost every growing system, the database is the first thing to buckle.

It is also the first thing engineers over-engineer in a panic. The instinct under load is to reach for the most dramatic move on the menu — “let's shard,” “let's go NoSQL,” “let's split into microservices.” Almost always the wrong order. There is a ladder here, with the cheapest and least invasive moves at the bottom, and the right answer is to climb it one rung at a time — only stepping up when the rung below genuinely runs out.

CLIMB IN ORDER — CHEAP & REVERSIBLE FIRST, EXPENSIVE & PERMANENT LAST CLIMB IN ORDER — CHEAP & REVERSIBLE FIRST, EXPENSIVE & PERMANENT LAST 1 · Index fix the query 2 · Scale up a bigger box 3 · Read replicas split the reads 4 · Cache skip the query 5 · Shard split the data cheaper · easy to reverse costly · hard to undo
The scaling ladder. Each rung is cheaper and easier to undo than the one to its right. Almost every team that gets into trouble jumped straight to rung five for a problem that rung one would have solved.

Rung 0: Measure first — it's probably one query

Before you scale anything, find out why it is slow. Nine times out of ten, early on, the answer is not “the database is too small.” It is one missing index, one N+1 loop firing a hundred queries to render a page, or one innocent-looking query doing a full table scan across a million rows.

An index is the single highest-leverage, cheapest thing in this entire article. The right one can turn a two-second query into a two-millisecond one without adding a single server — because it lets the database jump straight to the rows it needs instead of reading the whole table.

-- Ask the database what it is actually doing
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- "Seq Scan on orders ... rows=2,000,000"  ← reading everything

-- One line that changes everything
CREATE INDEX idx_orders_customer ON orders (customer_id);
-- now: "Index Scan ... rows=37"  ← jumps straight to the matches
The most expensive mistake on this page

Throwing hardware at a missing index. You will pay a bigger server bill every month, forever, to avoid writing a one-line migration once. Always reach for EXPLAIN before you reach for your wallet.

Rung 1: Scale up before you scale out

When the box itself is genuinely the limit — CPU pegged, RAM exhausted, disk I/O saturated — the next move is the boring one: vertical scaling. Give the database a bigger machine. More RAM (so more of your data lives in memory), faster disks, more cores.

It is unglamorous, and that is exactly the point. No code changes, no new failure modes, no new concepts for the team to learn. It buys you time and headroom while you do something smarter.

It does have three honest limits. There is a ceiling — the biggest instance money can rent. There is a cost cliff — pricing turns sharply super-linear at the top end, so the last doubling costs far more than the first. And it remains a single point of failure: one box, and rebooting it for that upgrade is downtime. But do not let those scare you off prematurely. The largest managed instances today are enormous; a single well-tuned database serves more traffic than the vast majority of companies will ever have. Do not distribute until you have actually hit the wall.

Rung 2: The read/write asymmetry → read replicas

Here is the insight that the next two rungs are built on: almost every application reads far, far more than it writes. A social feed, a product catalogue, a news site — they might serve a hundred or a thousand reads for every write. Your write load is tiny; your read load is the firehose.

So stop making one machine do both. Keep a single primary that takes every write, and add read replicas — copies that continuously stream the primary's data and serve read queries. Now you can add replicas to soak up read traffic almost linearly, and you get a failover candidate for free if the primary dies.

An application sends every write to a single primary, while reads are spread across several read replicas that asynchronously copy the primary's data. READS SCALE OUT · WRITES STAY ON ONE PRIMARY Application Primary writes Replica A Replica B all writes reads spread out async replication · ~ms lag a replica is always a little behind — that gap is replication lag
Most apps read far more than they write, so one primary takes every write and a fleet of replicas absorbs the reads. The price is replication lag: a replica is always a heartbeat behind the primary.

The catch has a name: replication lag. Replication is asynchronous, so a replica is always a little behind — usually milliseconds, occasionally seconds when it's busy. This is eventual consistency arriving in your own database, and it produces a classic bug: a user posts a comment (write → primary), the page reloads and reads a replica that hasn't caught up yet, and their comment appears to vanish. They post it again. Now there are two.

Living with the lag

Route reads that must see the latest write back to the primary — “read-your-own-writes” for the few flows that need it (a user looking at the thing they just changed). Let everything else — search, feeds, dashboards, analytics — happily read a slightly stale replica. The skill is deciding, per query, whether a one-second-old answer is fine. Usually it is.

Rung 3: Don't hit the database at all → caching

The fastest query is the one you never run. For data that is read often, expensive to compute, and changes rarely, put a cache (Redis, Memcached) in front of the database and answer from memory.

The everyday pattern is cache-aside: look in the cache first; on a miss, read the database, store the result, and return it.

async function getProduct(id) {
  const hit = await cache.get(`product:${id}`)
  if (hit) return JSON.parse(hit)        // fast path: served from memory

  const row = await db.products.findById(id)  // miss: pay for the query once
  await cache.set(`product:${id}`, JSON.stringify(row), { ttl: 300 })
  return row
}

Caching is the highest reward-to-effort move on the ladder — and the one with the sharpest edges. There's an old joke that the two hardest problems in computer science are cache invalidation and naming things. The joke is about the first one. The moment the underlying data changes, your cache is wrong, and deciding when and how to refresh it (expire on a timer? delete on write? both?) is the real work. A second trap is the cache stampede: a hot key expires, a thousand requests all miss at once, and they all hammer the database together — the exact spike the cache existed to prevent.

A cache is an optimisation, never the truth

The database is the source of record; the cache is a fast, disposable opinion of it. Design so that if the entire cache vanished this second, you'd serve slower for a few minutes — not lose data or serve corruption. If losing the cache loses data, you've built a fragile database, not a cache.

Rung 4: When one box can't hold the writes → partitioning & sharding

Everything so far scales reads and keeps a single primary for writes. Eventually — and for most companies this is much later than they fear — the write volume or the sheer size of the data outgrows one machine. Now you have no choice but to split the data itself. There are two shapes.

Vertical partitioning splits by columns or tables: move a rarely-used giant blob column, or a whole table, onto its own database. Taken to its logical end across a business, this is just database-per-service — each service owning its slice.

Horizontal partitioning, better known as sharding, splits the rows of one big table across many databases by a shard keyuser_id by hash, customer by region, order by date range. Each shard is a normal database holding its slice of the rows.

A shard router uses a shard key, such as user_id modulo three, to decide which of several independent database shards stores each row. THE SHARD KEY IS THE ONE DECISION YOU CAN'T EASILY UNDO Shard router key → which shard? Shard 0 user_id % 3 == 0 Shard 1 user_id % 3 == 1 Shard 2 user_id % 3 == 2 right key → most queries hit one shard · wrong key → hot shard + scatter-gather
Sharding splits the rows of one table across many independent databases by a shard key. Pick the key well and most queries touch exactly one shard; pick it badly and you get hot shards and slow cross-shard queries.

The shard key is the most important and most permanent decision in this whole article. A good key does two things: it spreads load evenly (so you don't get a “hot shard” melting while the others idle), and it aligns with your most common query (so that query lands on a single shard). Get it wrong and you live with the consequences for years.

And the bill is steep, which is exactly why this is the top rung:

  • Cross-shard queries hurt. “Top 10 orders across all customers” now means asking every shard and merging the results — a scatter-gather that is slow and fiddly.
  • Cross-shard transactions are gone. You're back to sagas and eventual consistency — the distributed-data problem, now inside your storage layer.
  • Resharding is a real project. Splitting a shard that grew too hot, or changing the key, means migrating live data without downtime. Teams plan for this months ahead.

A note on SQL vs NoSQL: it's about access patterns, not “web scale”

Somewhere along the way someone will say you need to “switch to NoSQL to scale.” Usually that's a myth. A relational database, climbed up the ladder above, scales to a genuinely enormous size — companies you've heard of run staggering load on boring, sharded Postgres or MySQL.

You reach for a different store when the access pattern is genuinely different, not because relational “doesn't scale.” A key-value store for massive simple-lookup throughput; a document store for flexible, nested schemas; a search engine (Elasticsearch) for full-text; a time-series database for metrics; a wide-column store (Cassandra) for write-heavy workloads with a known query shape. Each is a precision tool for a pattern relational SQL serves awkwardly.

The honest default

Stay on your boring relational database far longer than the internet suggests. “Polyglot persistence” — the right store for each job — is a real and good idea at scale, but every new datastore is one more thing to operate, back up, monitor, and reason about consistency across. Add the second database when a specific pattern demands it, not because one sounds more advanced.

Climb the ladder in order

The symptom you actually haveThe cheapest fix that works
One endpoint is slowEXPLAIN it — add the missing index, kill the N+1
The whole DB is near its CPU/RAM limitScale up to a bigger instance first
Reads are the bottleneck, writes are fineRead replicas — and route must-be-fresh reads to the primary
The same expensive reads, over and overA cache in front (cache-aside + TTL)
Write volume or data size exceeds one boxPartition, then shard by a carefully chosen key
A specific pattern fights the relational modelAdd a purpose-built store — and accept operating it

The honest view by company size

  • Solo / early startup. One database, well-indexed, backed up regularly, restore tested once. That is your entire scaling strategy, and it is the correct one. The biggest mistake at this stage is sharding for a load you don't have — a single managed Postgres serves more traffic than 99% of startups will ever reach.
  • Growing scale-up. Add a read replica when reads dominate (and for failover peace of mind). Put a cache on the few obvious hot paths. Scale the primary up when you must. Resist sharding until write volume genuinely forces your hand — and when it does, spend real time choosing the shard key. Measure relentlessly; let data, not fear, move you up a rung.
  • Enterprise. Replicas, caching tiers, and sharding (or a managed distributed-SQL database that shards for you) are normal furniture. The investment shifts from “which rung” to operational discipline: shard-key governance, cache invalidation as a platform concern, automated replica failover, and capacity planning. Polyglot persistence here is deliberate and owned — not the accidental sprawl of a dozen teams each picking a favourite database.

Key takeaways

  • The database falls over first because it holds state you can't just clone. Climb the ladder; don't leap to the top.
  • Measure before you scale. An index is the cheapest, highest-leverage fix on the whole page — reach for EXPLAIN before hardware.
  • Scale up before you scale out. One well-tuned box goes further than most teams believe.
  • Read replicas exploit the read/write asymmetry — but bring replication lag. Route reads that must see the latest write to the primary; let the rest read slightly stale.
  • Caching makes the fastest query the one you never run. Invalidation is the hard part, and the cache is never the source of truth.
  • Sharding is the top rung because the shard key is hard to undo. Reach for it only when writes or size truly exceed one machine — and choose the key like it's permanent, because it is.

Scaling the data layer is mostly the discipline of not doing the exciting thing first. Together with splitting code into services, deciding who owns the data, and paying the resilience tax, it's the last quiet piece of keeping a system standing as it grows — one cheap, reversible step at a time, for as long as that step keeps working.

你觉得这篇文章如何?