Replication, Partitioning, and Sharding Questions
Scaling and distributing data across nodes: primary-replica and multi-primary replication, read-replica scaling, horizontal partitioning, and sharding strategies with their key-selection and rebalancing challenges. Covers replication lag, failover and split-brain handling, cross-shard operations such as joins, distributed transactions, and global secondary indexes, and the operational cost of a partitioned topology. Key to designing databases that scale horizontally.
You operate a service with a primary DB and read replicas. One replica reports replication-lag histogram: 90th percentile lag = 2s, 99th percentile = 12s, max = 90s. What conclusions can you draw about staleness risk for user reads, and what immediate SRE actions or alerts would you configure to prevent user-visible anomalies?
Sample Answer
Direct answer
A p90 (90th percentile: 90% of measurements fall below this value) of 2 seconds with a p99 (99th percentile) of 12 seconds and a max of 90 seconds describes a replica that is comfortably caught up most of the time but has a heavy tail: roughly 1% of reads land during a window where the replica is 12 seconds or more stale, and there exists at least one moment where it fell 90 seconds behind. That shape (a moderate p90 but a p99 six times higher, and a max another 7.5 times past that) is the signature of periodic bursts, not steady degradation: something intermittently overwhelms the replica's apply capacity (a batch job, a large transaction, a backup) rather than the replica being uniformly a little behind all the time.
What the shape tells you
If lag were uniformly around 2 seconds, p90/p99/max would all sit close together. The large gap between p90 (2s) and p99 (12s), and the much larger gap between p99 and the max (90s), means the lag distribution is heavily right-skewed: most reads are fine, but a real subset are badly stale, and a rare subset are very badly stale. That points investigation toward looking for periodic, bursty causes (nightly jobs, large migrations, backup windows) rather than a capacity problem that would show up as elevated lag across the whole distribution.
Worked example: estimating user impact
reads_per_sec = 8000 # ASSUMPTION: illustrative traffic level for this replica, stated as an input, not measured
p99_lag_s = 12.0
p90_lag_s = 2.0
# by definition of a percentile, roughly 1% of reads are sampled during a moment
# where lag is at or above the p99 value, and roughly 10% during a moment at or
# above the p90 value (this is an approximation: it assumes read arrival is not
# correlated with lag spikes, which should be validated against real traffic
# patterns before treating it as precise)
bad_at_p99_per_sec = reads_per_sec * 0.01
bad_at_p99_per_day = bad_at_p99_per_sec * 86400
bad_at_p90_per_sec = reads_per_sec * 0.10
print(f"~1% of reads see >= {p99_lag_s}s staleness: ~{bad_at_p99_per_sec:.0f} reads/sec, "
f"~{bad_at_p99_per_day:,.0f} reads/day")
print(f"~10% of reads see >= {p90_lag_s}s staleness: ~{bad_at_p90_per_sec:.0f} reads/sec")
~1% of reads see >= 12.0s staleness: ~80 reads/sec, ~6,912,000 reads/day
~10% of reads see >= 2.0s staleness: ~800 reads/sec
At this traffic level, roughly 80 reads/second (about 6.9 million/day) could see data more than 12 seconds stale, which is a real, user-noticeable staleness window for anything read-your-writes-sensitive, even though the p90 alone (2 seconds) would look acceptable in isolation.
Immediate SRE actions
- Alert on the spread, not just one percentile. Alert on p99 crossing a threshold appropriate to the workload (say, page above 10 to 15 seconds for a user-facing OLTP replica, as in the general lag-alerting guidance for this kind of service), and separately track the max as a leading indicator: a growing max with a stable p99 suggests an emerging, not-yet-widespread problem worth investigating before it grows into the p99.
- Correlate lag spikes against time-of-day to look for a periodic cause (batch jobs, backups, reporting queries) before assuming a capacity problem.
- Take this replica out of rotation for the most staleness-sensitive traffic (read-your-writes paths) while investigating, routing that traffic to the primary or to a replica with better tail behavior, rather than waiting for root cause before limiting user impact.
- Add a per-replica lag SLO (service-level objective: an internal target the team commits to, for example "99% of reads see under 5 seconds of lag") with percentile-aware burn-rate alerting (alerting on how fast that target's allowed error budget is being consumed, not just a single instantaneous threshold check) so a recurring tail spike pattern like this one produces one clear, trackable signal instead of a series of one-off pages each time it happens.
Trade-offs and pitfalls
The mistake to avoid is treating a healthy-looking average or p50 as "replication is fine": with this exact data, a monitoring dashboard showing only average lag could easily read as green while a real, measurable fraction of users are seeing meaningfully stale data. The second mistake is over-reacting to the single max (90 seconds) as if it represents typical behavior; a single outlier data point does not justify architecture changes on its own, but it does justify finding out what specifically caused it, since a 90-second spike today is a plausible p99 tomorrow if its cause is left unaddressed.
Design a replication strategy for a write-heavy service serving 500k writes/second in a single region with strong consistency required for most operations. Explain whether you would use synchronous replication, leader sharding, partitioning, or a leaderless design; describe trade-offs for latency, availability, hardware costs, and operational complexity.
Sample Answer
Direct answer
At 500,000 writes/second with strong consistency required for most operations, plain leader-based replication on one primary is off the table (no single node accepts writes anywhere near that rate), and a fully leaderless design is off the table too (leaderless designs are built for tunable, usually eventual, consistency, not "strong for most operations"). The real choice is between (a) sharding the write key across many leader-replicated shards, each independently strongly consistent within itself, or (b) a managed distributed SQL system (for example CockroachDB, Google Spanner, or YugabyteDB) that shards automatically and replicates each shard's data via a consensus protocol (typically Raft), giving strong consistency both within a shard and, at extra latency cost, across shards via distributed transactions.
Why single-leader replication alone cannot meet this bar
Synchronous replication (required to make "strong consistency" durable across a failover) means every write waits for at least one follower to confirm. A single leader's write throughput ceiling on well-tuned hardware for small transactional writes is commonly in the low tens of thousands of writes/second (this is an illustrative planning figure, not a measured benchmark: real capacity depends heavily on row size, index count, and durability settings, and must be load-tested on the actual hardware and schema before being trusted). 500,000 writes/second against a ceiling in that range is roughly a 20 to 25x gap, which sharding closes by dividing the write volume across many leaders rather than by making one leader faster.
Option A: shard (partition) by key, replicate each shard
Sharding and partitioning are the same technique under two names here: both mean splitting the dataset by a key so no single node holds or writes all of it, and "leader sharding" specifically means each of those partitions keeps its own leader-replicated set rather than being spread across a leaderless quorum. Pick a shard key with high enough cardinality and even enough distribution that no single shard absorbs a disproportionate share of traffic (a "hot shard"), then run each shard as its own leader-replicated set (synchronous or semi-synchronous, per the sync/async trade-off) for durability and read scaling. Cross-shard operations (a transaction or a query touching two shard keys at once) either do not exist by design (the workload is chosen or reshaped so each unit of work touches one shard key, like a per-account ledger) or require a distributed transaction protocol: two-phase commit (every participating shard first PREPARES the write and votes yes or no on whether it can commit; only once every shard votes yes does a coordinator tell them all to COMMIT, and if any shard votes no or times out, every shard is told to ABORT instead, so the transaction is guaranteed to land everywhere or nowhere), or a saga pattern (the transaction is split into a sequence of separate local steps, each with its own predefined compensating action, like a refund undoing a charge, that runs to undo that step specifically if a later step fails, rather than any shard blocking on a vote). Either adds coordination latency exactly on the operations that need it, not on every write.
import math
target_wps = 500_000
per_shard_wps = 20_000 # ASSUMPTION: illustrative single-leader sustained write ceiling, not a benchmark
min_shards = math.ceil(target_wps / per_shard_wps)
headroom_shards = math.ceil(min_shards * 1.3) # 30% headroom for skew and single-shard failover load
print(f"minimum shards for {target_wps:,} writes/sec at {per_shard_wps:,}/shard = {min_shards}")
print(f"with 30% headroom for skew and failover = {headroom_shards} shards")
minimum shards for 500,000 writes/sec at 20,000/shard = 25
with 30% headroom for skew and failover = 33 shards
That is the arithmetic case for sharding: roughly 25 to 33 independently-replicated leader shards, each individually well within its write ceiling, rather than one impossible leader.
Option B: managed distributed SQL, for a write key that resists clean sharding
If no shard key cleanly isolates traffic (for example, a workload whose "hot" record changes constantly and unpredictably, so any fixed sharding scheme keeps producing a new hot shard), a system like CockroachDB is worth the extra cost: it splits data into small ranges automatically and rebalances them continuously, and each range is itself replicated via Raft consensus (a majority of replicas must agree on each write, giving strong consistency without a fixed, hand-picked leader-follower topology). The real trade-off against a hand-sharded leader-replicated design: every write pays a consensus round trip to a majority of that range's replicas (commonly a few milliseconds within a region, since Raft is not free even locally, and materially more across regions), and every cross-range (the equivalent of cross-shard) transaction pays two-phase-commit-like coordination cost baked into the engine rather than into custom application code. In exchange, resharding as traffic shape changes over time is the system's job, not a migration project.
Trade-offs and pitfalls
Hand-sharded leader replication wins on latency (a single-leader write inside one shard is one consensus-free round trip to a synchronous follower, not a Raft majority vote) and on using well-understood, battle-tested engines, but it makes you own shard-key selection, rebalancing, and cross-shard transaction handling as application-level engineering. Managed distributed SQL wins on operational simplicity and on tolerating a write key that resists clean sharding, but every single-row write pays a consensus round trip that a well-chosen leader shard would not, so if the workload genuinely does have a clean shard key (most workloads with a natural tenant, account, or user ID do), hand-sharding is usually the lower-latency, lower-cost choice, and reaching for distributed SQL becomes overengineering. The failure mode to avoid either way is discovering the hot-shard or hot-range problem in production rather than in shard-key design review: any scheme where one key sees disproportionate traffic reproduces the original single-leader bottleneck, just at the shard level.
What is consistent hashing and how does it reduce data movement when the number of shards changes? Explain the role of virtual nodes and how they improve load distribution on the hash ring.
Sample Answer
Direct answer
Consistent hashing maps both data keys and shard nodes onto the same circular numeric space (a "ring"), so a key belongs to whichever node's position is the next one clockwise from the key's hash. Because a node's ring position is independent of every other node, adding or removing one node only reassigns the keys in the arc next to it, roughly 1/N of all keys for N nodes, instead of remapping almost everything the way hash(key) mod N does when N changes. Virtual nodes (vnodes) give each physical shard many scattered positions on the ring instead of one, which smooths out the uneven arc sizes a handful of random points would otherwise produce and lets you hand a bigger shard proportionally more of the ring.
Structured elaboration
The ring mechanism
- Hash both shard identifiers and keys with the same hash function onto a fixed range, for example 0 to 2^32-1 ("the ring"), and treat that range as wrapping around like a clock face.
- Each physical shard is assigned one or more positions ("tokens") on the ring.
- To find a key's owner: hash the key, then walk clockwise from that point to the first token; that token's shard owns the key.
Why this bounds data movement when the shard count changes
The naive scheme owner(key) = hash(key) mod N recomputes every key's owner from scratch whenever N changes, because the modulus itself changed; in practice that remaps close to all keys (demonstrated below: 85.5% for a 6-to-7 shard change). Consistent hashing only moves the keys whose ring position falls inside the arc that changes ownership. Insert a 7th node's token and it slices a piece off of whichever single node's arc it lands inside; every other node's arc is untouched. The maximum fraction remapped is therefore bounded by roughly the new node's share of the ring (about 1/7, 14.3%, for 6 to 7 shards), and only the new node gains keys; the six existing shards never trade keys with each other.
The role of virtual nodes
A physical shard placed at a single random ring position owns one contiguous arc, and with only a handful of shards those arc lengths vary a lot by chance, so some shards end up owning far more of the ring than others even though the expected share is 1/N for everyone. Giving each shard many small, scattered arcs instead of one large one buys three things:
- Load smoothing: with many small arcs per shard, the total arc length each shard owns converges toward its fair share as vnode count grows (demonstrated below: standard deviation drops from about 50% of the mean at 1 vnode/shard to about 4% at 100 vnodes/shard).
- Uneven capacity, on request: assigning a bigger shard more vnodes than a smaller one gives it a proportionally larger slice of the ring without changing the hashing algorithm at all: a node with twice the capacity of its peers simply gets twice as many tokens, so its expected share of ring traffic scales with its vnode count.
- Even rebalancing on add or remove: because a shard's ownership is spread across many small arcs instead of one, when it joins or leaves, the affected keys come from, or go to, many other shards roughly evenly instead of dumping the whole burden on whichever one shard happened to be its ring neighbor.
Choosing a vnode count
More vnodes improve balance but are not free:
- Metadata and coordination cost: every vnode is an entry the routing layer, or every node in a gossip-based system, must store and keep synchronized; at very high counts (hundreds of thousands across a cluster) that metadata and its propagation traffic become a real operational tax.
- Diminishing returns: balance improves fastest at low counts and flattens out, because it is a statistical variance-reduction effect, not a fixed formula, so moving from 1 to 32 vnodes buys far more balance than moving from 128 to 256 does.
- A concrete, checkable data point: Apache Cassandra (a widely used distributed, wide-column NoSQL database, an example of exactly the kind of node ring described above) shipped for years with 256 tokens (vnodes) per node by default, to guarantee smooth balance through brute-force randomness alone. Cassandra 4.0 lowered the shipped default to 16, paired with a replication-factor-aware token allocation algorithm (
allocate_tokens_for_local_replication_factor) that deliberately places each new node's tokens for balance, rather than relying on a large random count to average out skew. The project's own public debate over the new default (proposals ranged from 4 to 16 tokens) weighed availability and repair blast-radius against small-cluster balance, and settled on 16 as the practical compromise (Apache Cassandra dev mailing list, "num_tokens default in Cassandra 4.0" thread). - Rule of thumb: pick a base vnode count that keeps the worst-case imbalance,
max shard share / mean shard share, under whatever your operations team can tolerate at the smallest cluster size you expect to run, and prefer placement-aware allocation over pure randomness once the vnode count is high enough that its metadata cost is noticeable.
Worked example
This is a full, runnable simulation (Python standard library only, deterministic hashing via hashlib.md5, no randomness) comparing naive mod-N reassignment to consistent hashing at 1 and 100 vnodes/shard when a 6-shard ring grows to 7 shards, then measuring how vnode count affects load balance across a fixed 6-shard ring.
import hashlib, statistics
def h(key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
RING_SIZE = 2**32
def ring_hash(key):
return h(key) % RING_SIZE
NUM_KEYS = 20000
keys = [f"key-{i}" for i in range(NUM_KEYS)]
def naive_assign(key, n):
return h(key) % n
moved_naive = sum(1 for k in keys if naive_assign(k, 6) != naive_assign(k, 7))
print(f"naive hash-mod-N: nodes 6->7, keys remapped = {moved_naive}/{NUM_KEYS} = {moved_naive/NUM_KEYS:.1%}")
def build_ring(node_names, vnodes_per_node):
ring = {}
for node in node_names:
for v in range(vnodes_per_node):
token = ring_hash(f"{node}#{v}")
ring[token] = node
return sorted(ring.items())
def lookup(sorted_ring, key):
tok = ring_hash(key)
lo, hi = 0, len(sorted_ring)
while lo < hi:
mid = (lo + hi) // 2
if sorted_ring[mid][0] < tok:
lo = mid + 1
else:
hi = mid
if lo == len(sorted_ring):
lo = 0
return sorted_ring[lo][1]
nodes6 = [f"shard-{i}" for i in range(6)]
nodes7 = nodes6 + ["shard-6"]
for vnodes in (1, 100):
ring6, ring7 = build_ring(nodes6, vnodes), build_ring(nodes7, vnodes)
before = {k: lookup(ring6, k) for k in keys}
after = {k: lookup(ring7, k) for k in keys}
moved = sum(1 for k in keys if before[k] != after[k])
print(f"consistent hashing, {vnodes} vnode(s)/node: nodes 6->7, keys remapped = {moved}/{NUM_KEYS} = {moved/NUM_KEYS:.1%}")
print()
for vnodes in (1, 8, 32, 100, 256):
ring = build_ring(nodes6, vnodes)
counts = {n: 0 for n in nodes6}
for k in keys:
counts[lookup(ring, k)] += 1
vals = list(counts.values())
mean, stdev = statistics.mean(vals), statistics.pstdev(vals)
print(f"vnodes/node={vnodes:>3}: per-shard key counts min={min(vals)} max={max(vals)} mean={mean:.0f} "
f"stdev={stdev:.0f} ({stdev/mean:.1%} of mean) max/mean={max(vals)/mean:.2f}x")
Output:
naive hash-mod-N: nodes 6->7, keys remapped = 17098/20000 = 85.5%
consistent hashing, 1 vnode(s)/node: nodes 6->7, keys remapped = 1924/20000 = 9.6%
consistent hashing, 100 vnode(s)/node: nodes 6->7, keys remapped = 2595/20000 = 13.0%
vnodes/node= 1: per-shard key counts min=1028 max=5885 mean=3333 stdev=1663 (49.9% of mean) max/mean=1.77x
vnodes/node= 8: per-shard key counts min=2170 max=4218 mean=3333 stdev=713 (21.4% of mean) max/mean=1.27x
vnodes/node= 32: per-shard key counts min=2634 max=4290 mean=3333 stdev=538 (16.1% of mean) max/mean=1.29x
vnodes/node=100: per-shard key counts min=3187 max=3574 mean=3333 stdev=136 (4.1% of mean) max/mean=1.07x
vnodes/node=256: per-shard key counts min=3140 max=3696 mean=3333 stdev=213 (6.4% of mean) max/mean=1.11x
Two things to read off this: naive mod-N remaps 85.5% of keys on a one-node change, versus 9.6 to 13.0% for consistent hashing (both close to the roughly 14.3% theoretical minimum of 1/7), confirming the data-movement bound. Separately, going from 1 to 100 vnodes per node shrinks the standard deviation of per-shard load from about half the mean to about 4% of it, confirming the load-smoothing effect; note the 1-vnode run still lands near the 14.3% theoretical remap fraction on average even though its per-shard load is far less even, because the two effects (data moved on a topology change vs. steady-state balance) are related but distinct.
Trade-offs and pitfalls
- Consistent hashing alone still treats every physical shard as equally capable; without weighting via vnode count, a bigger box receives no more traffic than a small one. Fixing that just means scaling vnode count with capacity, more tokens for a bigger box, fewer for a smaller one, so the ring hands out load in proportion to what each shard can actually handle.
- Drawback worth naming directly: the ring's vnode-to-shard mapping is itself state that must be distributed and kept in sync, whether via gossip or a directory service, and a stale copy on any client routes requests to the wrong shard until refreshed. This coordination cost grows with vnode count.
- If the hash function used to place tokens is not close to uniform, the "roughly 1/N" bound degrades; always use a well-distributed hash such as MD5, MurmurHash, or a SHA variant, never something like a raw modulo of a monotonically increasing ID as the ring hash.
- Changing the hash function, or the ring's overall size, on a live cluster is equivalent to changing the entire assignment function and remaps close to 100% of keys; ring parameters are effectively part of the schema, not something to casually redeploy.
Tail latency spikes increased after increasing shard count. Outline how you would diagnose root causes (network issues, coordination overhead, compaction/GC, metadata lookups), which tools and traces you'd use, and what optimizations you would try to reduce tail latency in a sharded datastore.
Sample Answer
Direct answer
Diagnose a post-shard-count-increase tail-latency regression by checking, in order, whether queries are actually using the shard key (an un-keyed query now fans out to more shards than before, which alone degrades p99 through pure order-statistics, no per-shard slowdown required), then network and coordination overhead (more shards means more connections, more routing hops, more chances one participant in a fan-out is briefly slow), then compaction/GC pauses and metadata-lookup cost on individual shards. The mechanism that most directly explains "latency got worse specifically because shard count went up" is scatter-gather fan-out width itself, demonstrated below.
Structured elaboration
Root-cause checklist, in likely-impact order
- Un-keyed queries forcing full fan-out: if a query's
WHEREclause does not include the shard key (the column value the router uses to know which single shard should hold a given row), the router cannot route it to one shard, it must scatter-gather to ALL of them. As shard count grows, that same query fans out wider, and p99 latency, the max across the fan-out, degrades purely from that width even if every individual shard's own performance is completely unchanged (demonstrated below). - Full-table scans on individual shards: check the query plan on each shard (
EXPLAIN ANALYZE, the command that prints the actual steps and timing the database used to run a query) for a sequential scan (reading every row in the table, in order, to find matches) where an index scan (using an index to jump straight to the matching rows instead of reading the rest of the table) was expected; this often surfaces alongside symptom 1 above, because a query written assuming it always hits one small, well-indexed shard can silently degrade into a full scan per shard once it starts fanning out to many, especially if the shard-routing key was dropped from an index or from the query itself during some earlier change. - Network and coordination overhead: more shards means more open connections from the router/proxy tier, more routing-table lookups per request, and more individual RPCs to wait on for any multi-shard query; check connection-pool saturation and per-hop latency in a distributed trace, not just aggregate query time.
- Compaction and garbage collection pauses: on LSM-tree-backed storage engines (engines like RocksDB or Cassandra's storage layer that buffer writes in memory and periodically merge them into sorted files on disk instead of updating rows in place) or garbage-collected ones, adding shards (if it meant redistributing existing write load onto more, smaller instances, or if total write volume grew alongside the resharding) can shift the compaction schedule (the background job that merges and rewrites those sorted files to keep reads fast, briefly competing with foreground traffic for CPU and IO while it runs) or GC schedule on individual nodes; check per-node compaction/GC pause metrics for a correlation in time with the latency regression.
- Metadata/routing lookups: confirm the routing-table cache hit rate has not dropped; more shards means a larger routing table, and if any tier is doing an uncached or poorly-cached lookup per request, that cost grows with the size of the table it is searching.
Tools and traces
- Distributed tracing with one span per shard hop for a fan-out query, so you can see the actual per-shard latency distribution behind an aggregate p99, not just the aggregate number.
- Per-shard
EXPLAIN ANALYZEon the specific query pattern that regressed, to catch symptom 2 directly. - Connection-pool and routing-cache metrics (pool wait time, cache hit rate) to catch symptom 3 and 5.
- Storage-engine internal metrics (compaction queue depth, GC pause histograms) correlated by timestamp against the latency regression, to catch symptom 4.
Optimizations to try
- Push the shard key into every query that can legitimately use it, and add monitoring that flags un-keyed queries against a sharded table as a class of technical debt, since they get strictly more expensive as the cluster grows, not just today.
- For queries that genuinely need to touch many shards, narrow the fan-out where possible (route only to shards known to hold relevant data, if any partitioning scheme allows that) rather than always scattering to every shard.
- Keep-alive and pool connections between routers and shards rather than establishing a fresh connection per request, to remove per-hop connection-setup cost from the tail.
- Cache routing metadata aggressively at every router/proxy instance (a local, periodically-refreshed copy of the routing table, not a lookup per request) so metadata lookup cost does not scale with routing-table size on the hot path.
Worked example
The fan-out-width mechanism, isolated: this is an illustration of pure order statistics using synthetic, unitless latency samples (not a measurement of any real system, deliberately unitless to avoid implying otherwise), holding each shard's own latency distribution completely fixed while only the fan-out width N changes, to show that p99 degrades from width alone.
import random
random.seed(42)
def per_shard_latency_au():
return max(0.1, random.gauss(5, 1.5)) # arbitrary units (a.u.), unchanged as N grows
def simulate_fanout(n_shards, n_queries=8000):
samples = sorted(max(per_shard_latency_au() for _ in range(n_shards)) for _ in range(n_queries))
p99 = samples[int(len(samples) * 0.99)]
return p99, sum(samples) / len(samples)
single_shard_samples = sorted(per_shard_latency_au() for _ in range(8000))
single_p99 = single_shard_samples[int(len(single_shard_samples) * 0.99)]
print(f"single-shard p99 (n=1, no fan-out): {single_p99:.2f} a.u. <- unchanged baseline throughout")
for n in (8, 32, 128, 512):
p99, mean = simulate_fanout(n)
print(f"fan-out width N={n:>4}: p99={p99:6.2f} a.u. ({p99/single_p99:.2f}x baseline), mean={mean:5.2f} a.u.")
Output:
single-shard p99 (n=1, no fan-out): 8.48 a.u. <- unchanged baseline throughout
fan-out width N= 8: p99= 9.62 a.u. (1.13x baseline), mean= 7.15 a.u.
fan-out width N= 32: p99= 10.06 a.u. (1.19x baseline), mean= 8.10 a.u.
fan-out width N= 128: p99= 10.66 a.u. (1.26x baseline), mean= 8.88 a.u.
fan-out width N= 512: p99= 11.25 a.u. (1.33x baseline), mean= 9.56 a.u.
p99 climbs from 1.00x to 1.33x baseline purely from widening the fan-out from 8 to 512 shards, with the per-shard distribution never changed. This is the concrete argument for checklist item 1 above: a p99 regression that lines up in time with a shard-count increase, on a query pattern that fans out, does not require ANY individual shard to have gotten slower to be fully explained; it can be the arithmetic of "the tail is the max of more independent draws," and checking for that first, before chasing a per-shard regression that may not exist, saves real investigation time.
Trade-offs and pitfalls
- Jumping straight to compaction/GC tuning (item 4) because it is a familiar lever is a common wrong turn; if the actual cause is fan-out width (item 1), no amount of per-shard tuning will fix a p99 that is fundamentally an order-statistics effect of querying more shards per request.
- A full-table-scan symptom on an individual shard can be a genuine, independent regression OR a downstream consequence of item 1 (a query that used to be a fast, single-shard indexed lookup silently became a per-shard full scan once it started fanning out without its shard-key filter surviving the rewrite); check both, in that order, rather than assuming they are unrelated.
- Reducing fan-out by routing only to "likely relevant" shards is a real optimization but only works if your partitioning scheme actually correlates with the query's filter; applying it blindly to a genuinely unpredictable access pattern just reintroduces missed-data bugs to chase a latency number.
- Treating this as purely a network or infrastructure problem, without first confirming whether the regressed queries are even using the shard key, risks spending real engineering effort on connection pooling and tracing infrastructure when the actual fix is adding a
WHEREclause.
Clarify the difference between logical partitions and physical partitions. As a Solutions Architect, when would you recommend exposing logical partitions in the application versus enforcing physical shard boundaries in the database layer?
Sample Answer
Direct answer
A logical partition is a boundary the application or query layer reasons about, for example "this tenant's data" or "this year's orders", that may or may not correspond to a separate physical storage unit underneath. A physical partition (or shard) is an actual, enforced storage boundary: a distinct table, file, or node that the database itself uses to place and retrieve data. As a Solutions Architect, I would expose logical partitions in the application when the boundary is about organizing access and reasoning (tenant isolation in queries, a clean API shape) but the underlying data still fits comfortably in one physical store; I would enforce physical shard boundaries in the database layer once that boundary needs to do real work, capacity limits, independent scaling, or a hard isolation guarantee that only separate physical storage can provide.
Structured elaboration
What "logical" buys you without committing to physical separation:
- A
tenant_idcolumn plus aWHERE tenant_id = ?on every query is a logical partition: from the application's point of view, each tenant's data is cleanly separated, but physically it can all live in the same table, same disk, same instance, possibly even interleaved with other tenants' rows. - This is cheap to build, easy to change (adding a new logical partition is just a new value, not a new piece of infrastructure), and is enough when the goal is organizational clarity or per-tenant querying, not capacity or hard isolation.
What forces the move to physical:
- Capacity: once the row count or storage size genuinely exceeds what one instance handles well, a logical boundary alone does nothing, the bytes are still all on one disk. Only a physical shard actually distributes storage.
- Independent scaling or a noisy-neighbor problem: if one tenant's query load can degrade another tenant's latency because they share the same physical instance, a logical
WHERE tenant_id = ?filter does not stop that; only physically separating the heaviest tenants onto their own shard (or instance) does. - A hard isolation or compliance requirement: "this tenant's data must never share physical storage/instance with that tenant's data" (a real requirement in some regulated industries or enterprise contracts) cannot be satisfied by a logical boundary at all, since the data is still commingled underneath regardless of what the query layer promises.
- Blast radius: a physical partition failing (disk corruption, an instance going down) only takes down the tenants physically on it; a purely logical boundary on shared physical storage means every tenant is exposed to the same failure.
How I'd sequence it in practice: start logical (a tenant_id column, a repository-layer filter) because it is nearly free and reversible. Move specific tenants (or the whole system) to physical sharding only when one of the forcing conditions above actually shows up, usually driven by a handful of large tenants first ("shard the top 20 tenants onto dedicated storage, keep the long tail on shared logical partitions") rather than an all-or-nothing migration.
Worked example
A SaaS analytics product starts with events(tenant_id, ...) as one table on one Postgres instance, with every query filtered by tenant_id, a purely logical partition. As the largest tenant grows to generate 40% of total write volume and its dashboard queries start showing up in the slow-query log during another tenant's peak hours, the fix is not "add an index", it's that the largest tenant needs physical isolation: move that one tenant's data to its own physical partition (a dedicated table, or in a sharded setup, its own shard), while the remaining tenants stay on the shared, logically-partitioned table. The tenant_id filter in application code does not change at all; only the routing layer (which physical table or shard a given tenant_id resolves to) changes. That is the concrete payoff of keeping the logical/physical boundary explicit and separate from day one: promoting one tenant to physical isolation did not require rewriting how every other tenant's queries are written.
Trade-offs & pitfalls
- Conflating the two is the main pitfall. A team that says "we've partitioned by tenant" when they mean a
WHERE tenant_id = ?filter has not actually gained any capacity, isolation, or blast-radius benefit, only organizational clarity in the code. That's a fine choice, but only if it was made knowingly. - Physical partitioning too early is real overhead for no benefit. Standing up per-tenant physical shards for a product with 50 small tenants, none near any capacity or isolation limit, adds routing complexity, cross-shard migration tooling, and operational burden that a logical
WHEREclause would have handled for free. - A routing layer between logical and physical is what makes the later migration cheap. If the application always resolves "which physical partition holds tenant X" through one place (a lookup table, a consistent-hash function, a directory service) rather than hardcoding it, promoting a tenant from shared logical storage to a dedicated physical shard is a routing-table change, not an application rewrite.
Unlock Full Question Bank
Get access to all Replication, Partitioning, and Sharding interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.