Sharding Is Hard, So Draw Good Boundaries: Tess Griffin on Shard Keys, Routing, and Proxies

Open on YouTube ↗
Overview

What do you do when your database has outgrown the biggest machine you can buy? Tess Griffin, a senior developer at thoughtbot, works through that question using a fictional multi-clinic healthcare app. Their central claim is that you don't make sharding easy by getting better at its hardest parts, such as cross-shard joins and distributed transactions. You choose boundaries so those parts rarely come up. The talk covers choosing a shard key, assigning and migrating data, routing queries with Rails' built-in sharding or with a proxy like PgDog, and handling the operations that still have to cross shards.

16 min read
0:28

The Scenario: A Database That Can't Get Any Bigger

Griffin opens with a familiar story. An app has run on one database for years, and as it grew, the team did the obvious thing and kept buying a bigger machine. In their hypothetical, the bill has reached $500,000 a month and the database still can't keep up. Queries that used to be instant now crawl, a small disturbance knocks the system over, and a traffic spike sets the whole database on fire without anyone knowing why. A bigger box either doesn't exist or costs too much.

Their first advice is to measure before doing anything drastic. Postgres itself can tell you whether the workload is read-heavy or write-heavy, which tables take the writes, and which tables have grown huge. If a few large tables absorb most of the writes, those tables are the ones to target.

1:44

Why Replicas Aren't Enough

If reads are the problem, Griffin says there is a cheaper fix: most read load can leave the primary entirely. A replica is a live copy of the database that serves reads, and you scale reads by adding more of them. Replicas have two limits, though. Every replica still holds every row, and the primary still takes every write. In Griffin's framing, a single SQL database deliberately trades slower writes for fast reads. When writes are the bottleneck, a single database has nowhere else to send them.

Table size is a separate problem. A table can grow so large that everything touching it slows down, including reads, writes, and even indexes. Vertical scaling has already been tried until the next size up didn't exist. The other direction is horizontal scaling: more machines rather than bigger ones. Sharding applies horizontal scaling to the database itself. One database becomes several, the rows are spread across them, and each machine takes a slice of the writes and holds a slice of the rows. Griffin presents this as a fix for both slow writes and giant tables.

3:20

The Shard Key and the Cost of Getting It Wrong

To split the data, you choose one column whose value decides where each row lives. That column is the shard key. A query that includes the shard key goes to exactly one shard. A query that doesn't include it must go to all of them. Griffin illustrates this with three shards: one query becomes three, every machine stops to answer, and two of them had nothing to return. If most queries look like that, sharding has bought you nothing.

As the ideal case, Griffin introduces "Napify," a storefront platform whose resemblance to other storefront platforms is, they joke, coincidental. Every table hangs off the shop. If you draw a circle around one shop, nothing sticks out, so the shard key is obviously the shop ID. The few things outside that circle, such as admins, plans, and feature flags, belong to no shop and stay on the primary.

4:23

The Case Study: A Clinic App on Fire

The real example is the speaker's fictional healthcare app, introduced with the same joke about originality. It serves hundreds of clinics from a single Postgres database, and nobody considered sharding until now. The tables currently falling over are visits and audit logs. The audit logs are especially large because the app aims to be HIPAA compliant, so every access of data must be logged.

The shard key has to split those two burning tables, and a table can only be split by a column it carries. A visit has four IDs: the patient, the practitioner, the clinic, and its own ID. Griffin treats each as a candidate, tries the cut, and looks at what breaks. On the slides, every red line is a query that lacks the key and therefore visits every shard.

Patient ID is tempting because patients are what the whole app is about. But patients belong to more than one clinic. As a result, half the queries wouldn't know which patient to route by, and the other half don't carry a patient at all.

Practitioner ID has the same kind of problem. Practitioners work at more than one clinic, and almost nothing in the schema connects to a practitioner. Griffin's rule: if almost nothing connects to your key, almost no query knows which shard to go to.

Visit ID, sharding on the burning table itself, would spread the load evenly, and Griffin acknowledges that some systems do this. In this schema, though, a visit owns almost nothing and touches everything. Everything about a visit lives elsewhere, so every query would cross shards. You can split the burning table and still not have picked the best shard key.

6:59

Why Clinic ID Wins

Griffin's guiding principle is to shard on the data that is easiest to separate, and in this app that is the clinic. Every write a clinic makes stays inside its circle. Only one relationship crosses the boundary: treatments point at remedies. But remedies are the same for every clinic, so the solution is to copy the remedies table to every shard so queries stay local. Billing has nothing to do with individual clinics, so it stays on the primary.

This cut also splits the fire. Audit logs and visits both carry a clinic ID, so each shard takes only its own clinics' logs and visits, and the load spreads across the new shards. Everything a clinic does happens on one machine. Joins work normally, and transactions are ordinary single-database transactions.

Griffin also points to an isolation benefit. If a query misfires or a shard is compromised, the damage stops at that shard's clinics rather than reaching all of them. On the earlier problem of patients who visit several clinics, Griffin explains that the app wants their records kept separate anyway: each clinic is its own covered entity and keeps its own patient records. And if a customer needs true single-tenant isolation, you can give them a shard of their own.

8:33

Assigning Clinics to Shards: Compute or Store

With clinic ID chosen, two jobs remain: moving the data onto the shards and routing every query to the correct one. Both can be done in Rails or delegated to a proxy. A proxy sits between the app and the database and speaks plain SQL, so the app still talks to one thing while the proxy spreads data across shards. For Postgres, Griffin names PgDog, an open-source proxy. For MySQL there is Vitess, with PlanetScale as the managed version. With a proxy, Rails' database.yml points at the proxy instead of directly at Postgres.

Before any data moves, every clinic needs an assigned shard. Griffin describes two ways to do this.

The first is to compute the shard by running the clinic ID through a hashing function. The simplest example is clinic ID modulo N, where N is the number of shards. Griffin warns that you probably don't want this in production, because changing N moves every clinic to a different shard. Whatever hashing function you choose, the property is the same: the function decides where a clinic lives, not you, so you can't move just one clinic. For a deeper treatment of hashing schemes, Griffin recommends Martin Kleppmann's Designing Data-Intensive Applications.

The second is to store the mapping in a table with one row per clinic: a clinic goes in, a shard comes out. The mapping is now data, and Griffin says that is the point, because you choose the placements. If one huge clinic is dragging down its shard, a single update can move it to a shard of its own. The trade-off is that every lookup goes through this table, so if it becomes unreachable, every shard behind it is effectively unreachable too. Griffin notes that the table is tiny and is written to only when a clinic is added or moved. The rest of the talk's code examples use this stored shard map.

11:30

Moving the Data

Doing the migration yourself is a lot of work, according to Griffin. You backfill the existing rows, catch up on the writes that arrived during the backfill, and then cut over, and each step is tricky. The copy must be throttled, or the copy itself can take down your database. Griffin calls this a fairly complex part of the project.

The alternative is to hand it to the proxy. With PgDog, you provision empty databases as your shards, create a publication, and run a reshard. PgDog fills the shards while the app keeps running. Griffin is careful to say the steps don't disappear: the backfill, catch-up, and cutover all still happen. What changes is who runs them.

12:45

Routing in Rails

Once the data is migrated, each incoming request needs to reach the right shard. Either the application picks, with Rails choosing a shard on every request, or the proxy picks, with the app treating it as one ordinary database.

Rails has supported multiple databases since 6.0 and sharding since 6.1, and Griffin notes that it works the same on Postgres and MySQL. Setup starts with configuration: every shard is an entry in database.yml, each on its own server with its own read replica. One abstract model then wires the shards together, connecting a writer and a reading replica for each, and the tenant-scoped models inherit from it. Remedies are identical on every shard, and non-clinic data like billing stays on the primary.

The core primitive is to ask the shard map which shard a clinic lives on, then wrap the work in connected_to. Everything inside the block runs against that shard. In Griffin's words, Rails answers exactly one question: which shard do I talk to?

14:20

Keeping Shard Logic in One Place

The shard key is a single detail, but once you shard, every query needs it, and Griffin warns that this logic tends to spread. You need a clinic's shard in the visits controller, so you open it there. Then the reminder job needs it, so you open it there too, and eventually it is everywhere. When you set up your data for sharding, you have to find every place that touches a clinic, and if each one handles routing on its own, it's easy to miss one.

Griffin's answer is to contain the logic in one place, and they note that Rails already ships configuration for shard support out of the box, which gives you a single place that knows how to find a shard. Going a step further, you can move routing out of the app entirely and onto the proxy: the app hands the proxy the shard key and the proxy does the routing. There is no shard map to maintain and no resolver to write. Griffin describes the result as the app going back to believing there is one database while you still get the benefits of sharding.

16:11

Background Jobs

Background jobs follow the same rule. Put the clinic ID in the job's arguments, then add a single around_perform in ApplicationJob that resolves the shard and wraps perform in connected_to. One place decides, and individual jobs never mention shards. If someone enqueues a job without a clinic ID, it raises an error instead of quietly hitting the wrong shard.

With a proxy, every process connects to the same endpoint. You use the same around_perform, but instead of connected_to, it issues a SQL SET LOCAL that sets the sharding key inside a transaction. Griffin flags one detail for Solid Queue: because it is database-backed, its tables should stay on the primary or get their own database, and should not be sharded. The SET LOCAL approach is the same with Sidekiq. Whether you use Rails or a proxy, Griffin's point is that the sharding logic should live in one place.

17:18

Outgrowing the Original Shards

When you outgrow the shards you started with, Rails leaves resharding to you. With a hashing function, the function decides what moves. With a stored shard map, you move only the clinics you choose. Griffin argues the table costs more to set up but pays for itself the first time you need more shards. If a very large clinic needs its own shard, you spin one up, point the clinic's row at it, and you're done. A proxy, by contrast, can reshard while the app keeps serving traffic, and since you never wrote a mapping, there is no mapping to get wrong.

18:24

Cross-Shard Joins: Scatter-Gather

Because the shard key keeps a clinic's data together, most joins never cross a shard. The ones that must cross can't run as a single query. Instead, you ask each shard and merge the answers yourself, a pattern called scatter-gather: loop over the shards, run the same query on each, and combine the results, for example by adding up counts. A proxy does this automatically by fanning out cross-shard reads. You write one query, and it hits every shard and merges the results.

18:58

Cross-Shard Writes and Two-Phase Commit

A few writes do cross shards, and this is where Griffin says the real difficulty lies. On one database, a transaction is atomic: the write is only ever seen as a single action. Griffin states that keeping a transaction atomic across shards in that same sense isn't possible in a distributed system. Once data lives on machines that communicate over a network, you have to choose: either wait for every machine to agree before anyone answers, or answer now and let something catch up later. You can't have both.

Two-phase commit is the "wait for everyone" option. The coordinator asks every shard whether it can do the work, each shard locks what it needs and promises yes, and only if all of them promise does anyone commit. It stays transactional, but everyone waits on the slowest shard. If the coordinator dies mid-handshake, the shards are left in doubt: each has promised, can't take the promise back, and sits holding its locks. Griffin emphasizes that two-phase commit is a blocking protocol.

According to Griffin, the protocol itself is the smallest part of the problem. Postgres provides PREPARE TRANSACTION, and a prepared transaction survives crashes. The hard part of building this yourself is everything around it. If your code crashes between prepare and commit, every shard is stuck holding its locks, and Postgres won't clean that up for you. You have to write the code that notices stuck transactions and finishes them.

A proxy can do this instead. You write one plain transaction, and the proxy runs the two-phase commit. Griffin stresses that it provides the same guarantee with the same costs: the locks are still real, and you just didn't have to build the plumbing. The proxy gives you all-or-nothing behavior across shards, but it can't bring back the single-database atomicity you gave up when the data was split. A good shard key, Griffin says, means you need this for only a very small percentage of your writes.

What a Proxy Does and Doesn't Change

Griffin closes by restating the limits of the tooling. A proxy can hide the hard parts, but it doesn't remove them. A cross-shard join is still slow, and a two-phase commit is still fragile. In their view, the win was never making the hard parts easy. It was arranging things so you rarely have to deal with them.

They summarize the choice between approaches this way. In Rails, you own every piece: the migration, the mapping, the routing, the joins, and the transactions, and you have to keep each one correct and running. With a proxy, you can hand that work over while the app still thinks it is talking to one database, getting sharding's benefits without building the plumbing yourself.

The final message is about data layout rather than tooling. You don't beat sharding by working harder on its hard parts. You arrange your data so each tenant's work happens in one place and those parts barely come up. Get that right, Griffin argues, and most of sharding's pain never happens. Shard on the data that's easiest to separate: good sharding is good boundaries.