The question this answers
Which operations get harder once the data is no longer co-located, and what does each one cost now?
Single-partition operations retain the storage engine’s local guarantees — typically atomic, isolated and single-round-trip. Operations spanning P partitions guarantee nothing beyond per-partition atomicity unless a commit protocol is added; their latency is the maximum over P responses, not the mean; and any global invariant requires either coordination or an explicitly weakened definition.
Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.
A partition knows its own data completely and everyone else’s not at all. A coordinator knows only what the partitions have told it, and each of those answers was true at a different instant. Without a snapshot mechanism, a cross-partition read returns a composite of several different moments that never simultaneously existed.
A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.
The fan-out tax on reads
A query that cannot be answered from one partition must be sent to several and merged. Three costs stack up, and the third is the one that surprises people.
Work multiplies. P partitions each do work; the coordinator merges. For a LIMIT 10 ORDER BY x across 50 partitions, each must return its top 10 — 500 rows fetched to produce 10.
Latency becomes a maximum, not a mean. The query finishes when the slowest partition answers. If each partition independently answers within its p99 latency, the probability that *all* P are fast is 0.99^P. At P = 50 that is 61%; at P = 100 it is 37%. So a 100-way fan-out experiences its per-partition p99 on nearly two thirds of queries — the median of the fan-out is roughly the p99 of a shard. This is Fan Out to 100 and the Component’s Tail Becomes the System’s Median and it is the single most important number in this section.
The result is not a snapshot. Each partition answered at a different moment. A cross-partition COUNT can double-count a row that moved between partitions, or miss one, unless the reads are taken at a common snapshot timestamp — which requires either a global clock (There Is No Global Clock) or an explicit coordination step.
The mitigations are the usual ones and they are all about *avoiding* the fan-out rather than speeding it up: choose the partition key to match the dominant query, maintain a differently-partitioned copy for the other access pattern (Materialized Views: A Read Model That Lags), or, when you must fan out, use hedging (Send a Second Request After p95 and Take Whichever Answers First) and per-partition deadlines (Pass the Remaining Budget Down, Not a Fresh One) so one slow shard cannot hold the whole query.
| Partitions queried | P(all within p99) | Effective experience |
|---|---|---|
| 1assumption | 99% | The p99 is the p99 |
| 10assumption | 90% | One query in ten hits a slow shard |
| 50assumption | 61% | Two queries in five hit a slow shard |
| 100assumption | 37% | The median query hits a slow shard — the p99 has become the norm |
Joins, aggregations and the operations that survive decomposition
Joins. A join is cheap when both sides of the join key live in the same partition — co-partitioning. Choose the same partition key for orders and order_items and the join is local, forever. When they are not co-partitioned there are two strategies, both borrowed from parallel databases: broadcast the small side to every partition holding the large side (cheap when one side genuinely is small), or shuffle both sides by the join key so matching rows meet on the same node (The Shuffle Is the Job). Shuffle is correct and expensive; it moves data proportional to the input size, over the network, per query.
Aggregations decompose exactly when the function is associative and commutative. SUM, COUNT, MIN, MAX and AVG (as a sum/count pair) reduce partially at each partition and merge trivially — the fan-out cost is a handful of numbers per partition regardless of data size. That is why they feel free.
DISTINCT, COUNT(DISTINCT), MEDIAN and percentiles do not decompose. There is no partial result smaller than the data: to know the global distinct count you must, in principle, see every value. The practical resolutions are exact-but-expensive (shuffle by the value being counted so each distinct value lands in one place) or approximate-but-cheap (HyperLogLog for cardinality, t-digest or KLL for quantiles). Sketches are mergeable by construction, which restores the associative-merge property at the cost of a bounded error — this is precisely why they exist and why every large analytics system uses them.
Top-N is the interesting middle case: it decomposes, but only if each partition returns N candidates, which is exact for ORDER BY on a stored column and *not* exact when the ranking depends on a global quantity, such as a score normalised across the whole dataset.
- Associative + commutative → partial aggregate per partition, merge at the coordinator. Cheap and exact.
- Not decomposable → shuffle for exactness, or a mergeable sketch for a bounded approximation.
- Top-N → each partition returns N; correct only if the ordering key is local.
- Any aggregate over a moving dataset → needs a snapshot, or the number is a composite of several instants.
Secondary indexes: the same trade in different clothes
You partition users by user_id and now need to look one up by email. The index has to live somewhere, and there are exactly two choices.
Local (per-partition) index. Each partition indexes its own rows. Writes are cheap and atomic — the index entry is written in the same partition, in the same transaction, as the row. Reads by the indexed field must ask *every* partition, because the email could be anywhere. This is the fan-out tax on every lookup.
Global (term-partitioned) index. The index is itself partitioned, by the indexed value. A lookup by email hits exactly one partition. But now writing a user touches two partitions — the row’s and the index term’s — so the write is a distributed operation with all that implies: it can partially fail, leaving an index entry with no row or a row with no index entry.
Almost every system resolves the write side by making the global index asynchronous: the write commits locally and the index is updated shortly after. That is a real weakening — there is a window in which a lookup by email does not find a user who exists, and after a delete, one that does not. It is usually the right trade, and it must be a decision rather than a surprise.
The framing worth keeping: a global secondary index is a materialized view partitioned differently from its source, and every property of Materialized Views: A Read Model That Lags — asynchronous update, bounded staleness, the need for reconciliation — applies to it.
| Aspect | Local (document-partitioned) | Global (term-partitioned) |
|---|---|---|
| Write costprotocol | One partition, atomic with the row | Two partitions — needs a commit protocol or asynchrony |
| Read costprotocol | Fan-out to every partition | One partition |
| Consistencytypical | Index and row always agree | Index lags the row, or the write becomes distributed |
| Failure modetypical | Slow reads at high partition counts | Index entries without rows, or rows without entries |
| Good fortypical | Filtering within a known partition; low partition counts | Point lookups by an alternate key across the whole dataset |
Transactions and uniqueness: where it stops being a performance question
Everything above trades latency and money. Two things trade correctness, and they need naming separately.
Atomicity across partitions requires an agreement protocol. Two-Phase Commit: Buying Atomicity With a Promise gives it to you and gives you its failure modes in the same box: a coordinator crash between prepare and commit leaves participants holding locks with no authority to release them (The Blocking Window: When 2PC Stops and Waits), which is an availability outage on those rows for as long as the coordinator is gone. Sagas: Trading Isolation for Availability avoid the blocking by giving up isolation: the steps commit independently and a failure is handled by compensation, which is a new action rather than an undo (A Refund Is Not a Rollback). The third option is the one to reach for first — choose the partition key so the transaction fits inside one partition. Entity groups, aggregate roots, and "partition by the thing transactions are scoped to" are all the same idea, and it is the cheapest correctness technique available in this domain.
Uniqueness across partitions is subtler and catches people out. If users are partitioned by user_id and emails must be unique, the uniqueness check cannot be done in the user’s partition — the conflicting email lives elsewhere. A unique index is therefore *a differently-partitioned structure*, and creating a user atomically with its email claim is inherently a two-partition operation. The standard resolution is a reservation protocol: claim the email in the email-partitioned store first with a conditional write, then create the user, then confirm — with a sweeper for abandoned claims. See Distributed Uniqueness: One Name, Many Shards for the full treatment; the point here is that it is a *partitioning* consequence, not a database feature you lost.
And when the operation spans services rather than partitions of one store, it is the same problem with worse tooling — Atomicity Stops at the Process Boundary.
The design rule that makes all of this moot
Every technique above is a way of paying for a boundary you drew. The cheapest technique is to draw the boundary somewhere else.
Enumerate your operations. For each, ask: does it touch one partition or many? Then choose the partition key so that the *frequent* and the *correctness-critical* operations are single-partition, and only the rare, tolerant ones fan out. A system where 99% of operations are single-partition scales almost linearly; a system where 30% are cross-partition does not scale at all, because the cross-partition work grows with both traffic and partition count.
When two access patterns genuinely conflict — orders by customer *and* orders by product — the answer is usually not a cleverer key. It is two differently-partitioned copies of the data, kept in sync asynchronously, each serving one pattern. That is Materialized Views: A Read Model That Lags-shaped thinking, it costs storage and staleness, and it is nearly always cheaper than making every query fan out.
Key points
- A fan-out to P partitions experiences the slowest of P responses: at P = 100, the median query hits a per-partition p99.
- Aggregations decompose exactly when the merge is associative and commutative;
DISTINCTand percentiles need a shuffle or a mergeable sketch. - Joins are local when both sides share a partition key, and require broadcast or shuffle when they do not.
- A global secondary index is a materialized view partitioned by the indexed term — one-partition reads, two-partition writes, usually resolved by making it asynchronous.
- Atomicity and uniqueness across partitions cost correctness machinery, not just latency — and both are avoidable by choosing the partition key to contain them.
The chain, answered
Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.
- • A coordinator receives an operation and determines which partitions it touches.
- • Single-partition: forward it and return the answer. One hop, local guarantees intact.
- • Multi-partition read: send sub-queries with individual deadlines, collect, merge, and decide what to do about partitions that did not answer.
- • Multi-partition aggregate: push a partial aggregate down; merge partials if the function permits, otherwise shuffle or approximate.
- • Multi-partition write requiring atomicity: run a commit protocol, or decompose into independently committed steps with compensations.
- • Global constraint: consult the structure partitioned by the constrained value, reserve, then act, then confirm — with a sweeper for the ambiguous middle.
- • One partition of a fan-out is slow or unavailable, so the whole query is slow or incomplete.
- • Partitions answer at different times, producing an aggregate over a state that never existed at any instant.
- • A distributed write commits on some partitions and not others.
- • The coordinator crashes mid-protocol, leaving participants in prepared state holding locks.
- • An asynchronous global index falls behind, so lookups miss recent rows and find deleted ones.
- • A reservation is claimed but never confirmed or released, permanently retiring a value.
- • Fan-out tail: end-to-end p99 far worse than any individual partition’s p99, with every per-shard dashboard looking healthy. The operator sees a latency problem with no slow component, which is the defining signature of fan-out.
- • Partial results reported as complete: a fan-out query treats a timed-out partition as empty and returns an answer that is quietly wrong. Counts drift down during incidents and nobody notices because there is no error.
- • Orphaned index entries: an asynchronous global index contains rows that no longer exist. The operator sees lookups returning ids that 404 on fetch, at a rate proportional to delete volume.
- • Stuck reservations: a claim on a unique value is pending forever after a lost confirm. The operator sees users unable to register with an email that appears unused, and no error anywhere in the logs.
- • Locks held by an absent coordinator: rows on several partitions are unreadable or unwritable until an operator intervenes. The operator sees a small, precise set of keys timing out while everything else is fine.
- • Snapshot-free aggregation drift: the same report run twice within a minute returns different totals with no writes in between, because the two runs sampled partitions at different instants.
- • None for single-partition operations — the entire reason to choose a key that keeps operations local.
- • A fan-out read needs no agreement, only collection — but it needs a common snapshot if the answer must be internally consistent, and a snapshot is a coordination mechanism.
- • Atomic multi-partition writes need agreement on the commit decision. That is the expensive kind: it blocks, and its availability is the product of the participants’ availabilities.
- • Global uniqueness needs a single serialisation point per constrained value. Notice that the point is *per value*, not global — which is what makes it affordable, and what distinguishes it from a global lock.
- • The design objective throughout is to keep coordination out of the common path and confine it to the rare operations that genuinely need it (Coordination Avoidance: Restructuring the Problem Instead of Paying for It).
- • Single-partition operations continue normally for every partition that is up — partitioning preserves this containment even when cross-partition work is failing entirely.
- • A fan-out degrades to a partial answer. Whether that is acceptable is an application decision and must be made explicitly; defaulting to "return what we got" is how silent wrongness enters.
- • An in-doubt two-phase commit blocks a specific set of rows and nothing else — small blast radius, indefinite duration.
- • Asynchronous indexes and views continue serving stale data during an incident, which is usually preferable to failing, provided the staleness is visible.
- • Detect: distinguish "complete answer" from "partial answer" at the API level. A response that cannot say which it was cannot be recovered from.
- • Contain: per-partition deadlines and a policy for missing partitions, decided per query type rather than globally.
- • Recover: re-drive incomplete distributed writes from a durable record of intent; this is what a saga log or a transaction coordinator log is for.
- • Reconcile: sweep for orphaned index entries, pending reservations and in-doubt transactions on a schedule. Every cross-partition mechanism needs a sweeper, and the sweeper is the part that gets forgotten.
- • Verify: run periodic invariant checks that a single node cannot enforce — "every user has exactly one email claim, and every claim points at a user that exists" (Reconciliation Is a Component, Not a Cleanup Script).
- • Ratio of single-partition to multi-partition operations, by operation type. It is the single best predictor of whether the system will keep scaling.
- • Fan-out width per query — the number of partitions touched. A distribution with a long tail means some queries have no usable index.
- • Rate of partial results returned, and which partitions were missing.
- • Index lag for every asynchronous global index, in seconds and in rows.
- • Count of in-doubt transactions and pending reservations older than the sweeper threshold. Both should be near zero and neither is monitored by default.
- • When the cross-partition operation is rare and the alternative is a data model that fights every common query.
- • Analytics and reporting, where fan-out is inherent and latency expectations are measured in seconds.
- • Global indexes for a genuinely global lookup key — login by email is the canonical case, and fanning out every login is not viable.
- • Distributed transactions for the small number of operations where partial application is unacceptable and compensation is not possible.
- • On the hot path. A fan-out in the request path of every page view puts the p99 of your slowest shard in front of every user.
- • At high partition counts, where the tail-latency arithmetic turns a rare slow shard into the common case.
- • When atomic multi-partition writes become routine — you have rebuilt a single-node database with network latency between its pages and none of its guarantees.
- • When partial results are silently treated as complete, which converts a latency problem into a correctness one.
- • Repartition so the operation is local. The most effective option and the one requiring the most work.
- • Denormalise: store the joined data together at write time. Trades write amplification and staleness for local reads, and is the standard answer in wide-column and document stores.
- • A second, differently-partitioned copy of the data serving the other access pattern (Materialized Views: A Read Model That Lags).
- • Mergeable sketches instead of exact distinct counts and percentiles — bounded error, associative merge, orders of magnitude cheaper.
- • Sagas instead of two-phase commit when the steps can be compensated, trading isolation for availability.
- • Accept eventual consistency for the constraint and reconcile after the fact — viable when duplicates are detectable and cheap to resolve, and not viable for money or identity.
Scatter-gather: the query finishes when the slowest partition answers
| Cost | What it is here | Why it is not a tuning problem |
|---|---|---|
| work multiplies | 50 shards × 10 rows = 500 rows fetched to return 10 | ORDER BY across partitions needs every partition's top-K before it can pick the global top-K |
| latency is a maximum | 79 ms median, 318 ms p99 | The median of the fan-out approaches the p99 of a shard as P grows |
| the result is not a snapshot | 50 answers, 50 different moments | A COUNT can double-count a row that moved partitions, unless the reads share a snapshot timestamp |
What people believe, and what is true
A distributed join is just a join with more machines.
It is a network data-movement problem. Either one side is broadcast to every partition, or both sides are shuffled by the join key — and the shuffle moves data proportional to the input, per query.
Adding a global secondary index is free, like adding a local one.
It makes every write a two-partition operation. Systems hide this by making the index asynchronous, which means the index and the data are allowed to disagree for a while.
Fan-out is fine because each shard is fast.
You experience the slowest shard, not the average one. At 100 partitions, the median query encounters a per-shard p99.
Unique constraints are a database feature, so a distributed database gives them to me.
Only within a partition, unless the system runs a cross-partition protocol on your behalf and charges you for it. Global uniqueness is a differently-partitioned structure plus a reservation protocol, whoever implements it.
A cross-partition COUNT is exact.
Without a common snapshot it is a composite of several instants and can double-count or miss rows that changed during the query.
Go deeper
Only the levels this lesson can honestly fill — a missing level is a claim nobody had.
Overview
Once data is split, joins, transactions, aggregations, indexes and unique constraints all need a distributed mechanism. The cheapest fix is a partition key that keeps the important operations inside one partition.
Practical
List your operations and mark each single- or multi-partition before choosing the key. Push aggregates down when the merge is associative; use sketches when it is not. Give every fan-out a per-partition deadline and make a partial result explicitly distinguishable from a complete one.
Advanced
A global secondary index is a materialized view partitioned by the index term. That reframing tells you everything: the write is a two-partition operation, making it asynchronous buys availability at the cost of a staleness window, and it needs a reconciliation sweep because the two copies will diverge.
Internals
Every cross-partition mechanism has an ambiguous middle state — prepared but not committed, claimed but not confirmed, indexed but not stored — and every one of them needs a sweeper with a timeout and a documented rule for what the middle state means to a concurrent operation. The sweeper is not an optimisation; it is the half of the protocol that makes the other half safe, and it is the half that gets left out.
Apply it
- 🔧 Instrument one endpoint to record how many partitions it touches. Plot the distribution; the tail is where your missing index is.
- 🔧 Take an existing
COUNT(DISTINCT ...)report and re-implement it with a mergeable cardinality sketch. Measure both the cost and the error.
- ⚡ A search page fans out to all 64 shards and its p99 is 900ms while every shard reports a 40ms p99. Explain the arithmetic and propose two fixes.
- ⚡ After a partial network incident, your global index contains 12,000 entries pointing at rows that do not exist. What produced them, and what should have prevented it?
- 💬 You shard orders by
customer_id. Product managers want "top selling products this week". What are your options and what does each cost? - 💬 Explain why a fan-out to 100 shards has a p99 much worse than any individual shard.
- 💬 Design globally unique emails on a store partitioned by user id. What is the failure mode of your design and what cleans it up?