Data Consistency and Distributed Transactions Questions
Maintaining correctness of state across services and replicas: eventual consistency, conflict resolution (last-write-wins, CRDTs, vector clocks), the saga pattern, two-phase commit, and idempotency keys for exactly-once effects. Covers when to trade strict consistency for availability and how to reason about read-your-writes and monotonic guarantees. Focuses on the application/service layer rather than storage-engine internals.
Explain why two-phase commit (2PC) can block indefinitely and why three-phase commit (3PC) is rarely used in practice despite being designed to fix that. What non-blocking alternatives exist for cross-shard transactions, and how do they compare on safety, liveness, performance, and operational complexity?
Sample Answer
Direct answer: 2PC can block indefinitely because the commit decision lives in exactly one place, the coordinator's durable log. If the coordinator crashes after collecting votes but before every participant has received the decision, a participant that already voted yes cannot safely guess the outcome, so it must sit holding its locks until the coordinator (or someone with equivalent information) comes back. Three-phase commit (3PC) tries to fix this by adding an extra round, but it depends on assumptions that don't hold in real networks, so it's essentially never used.
Structured elaboration
Why 2PC blocks. The failure scenario is specific: all participants voted yes, so none of them may unilaterally abort (that would break atomicity if the coordinator had already decided to commit). But without the coordinator's decision, a participant also doesn't know it's safe to commit. It's stuck in an "in-doubt" state that only resolves once it learns the real outcome, either the coordinator recovers, or another participant that happens to already know the answer tells it (this only works if such a participant exists and can be reached).
What 3PC changes. 3PC inserts a "pre-commit" phase between prepare and commit: after everyone votes yes, the coordinator broadcasts PRE-COMMIT and waits for acknowledgments before sending the final COMMIT. The idea is that once a majority of participants have seen PRE-COMMIT, they know a commit decision was reached and can safely commit even without hearing directly from the coordinator, because a pre-commit message could only have been sent after unanimous yes votes.
Why 3PC still doesn't solve it in practice. The non-blocking property of 3PC relies on synchronous system assumptions: a known upper bound on message delay and processing time, so that a timeout reliably distinguishes "the coordinator crashed" from "the coordinator is just slow." Real networks are asynchronous: you cannot tell a slow coordinator from a dead one purely by waiting. If participants time out and elect a new coordinator while the old one is actually still alive but partitioned, you can get two coordinators making conflicting decisions, a split-brain that can violate atomicity, exactly the thing the protocol exists to prevent. 3PC also costs an extra network round-trip on every transaction, for a safety property it only delivers under an assumption that doesn't hold in production.
Non-blocking alternatives actually used in practice
| Alternative | Safety | Liveness | Performance | Operational complexity |
|---|---|---|---|---|
| Consensus-backed commit (e.g. running the commit decision through Raft/Paxos instead of a single coordinator) | The commit decision is durable and linearizable as long as a majority of coordinator replicas are non-faulty and non-Byzantine; losing a minority never loses the decision | Progresses as long as a majority of coordinator replicas can reach each other; a leader crash costs a brief re-election gap but recovers automatically, unlike a single 2PC coordinator that stays down until someone restarts it | One extra network round-trip (majority acknowledgment) per state transition versus a single-node coordinator; typically low single-digit-millisecond overhead within one region, more across regions | Highest: you now operate a consensus cluster, leader election, log compaction, membership changes, quorum-health monitoring, in addition to whatever else the team already runs |
| Avoid the pattern altogether: sagas with compensating actions | Gives up atomicity; intermediate states are externally observable, so correctness now depends entirely on every compensating action being semantically correct | Excellent: no cross-service locks are ever held, so a slow or dead step never blocks the rest of the system, it just delays that one saga | No extra coordination round-trip; each step commits as fast as that service's own local transaction commits | Moderate to high depending on the workflow: every step needs a correct, idempotent compensating action, a design cost paid once per step rather than an ongoing piece of infrastructure to operate |
| Timeouts plus heuristic decisions (commit-or-abort heuristics, "presumed abort") | Weakest of the three: a heuristic guess made after a timeout can be wrong (e.g. presuming abort when the coordinator had actually committed), a small but real correctness risk | Bounded by construction: a participant never waits past the chosen threshold | Cheapest option: no extra protocol phases, no replication | Lowest: just a timeout value and a documented default decision, but that simplicity is what pushes the risk into an occasional silent inconsistency that has to be caught by reconciliation later |
Worked example of the blocking window. Coordinator collects yes votes from participants P1 and P2, durably logs "commit", sends COMMIT to P1 (which applies it and moves on), then crashes before the message to P2 goes out. P2 is now holding its locks with no way to know the transaction committed. If P2 tries to reach P1, P1 can honestly tell it "I got COMMIT", which lets P2 also commit safely, that's the one case where a peer can rescue an in-doubt participant. If P1 is unreachable too, P2 has no choice but to keep waiting for the coordinator to restart.
Trade-offs and pitfalls. The most common mistake is treating "we compared timeouts and picked a value" as if it solves the blocking problem, it only bounds the WORST-case wait, it doesn't remove the possibility that the guess made after the timeout is wrong. Anyone proposing 3PC in an interview should be able to name the synchrony assumption it needs and explain why that's the actual reason it isn't deployed, not just "it's more complex."
Discuss the trade-offs between throughput and consistency when designing a service that requires high write throughput. What metrics would you collect to quantify the trade-off, and what patterns let you move some operations to eventual consistency while preserving correctness on the critical paths?
Sample Answer
Direct answer: The throughput-consistency trade-off shows up as added latency, coordination overhead, and reduced write concurrency the stronger your consistency guarantee gets; the metrics that quantify it are write latency (p50/p99), achievable write throughput per shard/partition, and lock/contention wait time, and the pattern for reclaiming throughput is to selectively relax consistency on the paths that can tolerate it while keeping strong guarantees only where correctness genuinely requires them.
Structured elaboration
Why the trade-off exists mechanically. Strong consistency requires coordination, a single leader serializing writes, or a quorum of replicas confirming before a write is acknowledged, and coordination costs time (a network round-trip, at minimum) and limits how many writes can be in flight concurrently without conflicting. Eventual consistency skips that coordination: a write is accepted locally and propagated asynchronously, no round-trip wait, no serialization bottleneck, dramatically higher achievable throughput, at the cost of the staleness and conflict-resolution concerns covered elsewhere in this topic.
Metrics to collect. Write latency distribution (not just average, the P99/P999 tail is usually where coordination overhead shows up most painfully, since a quorum write's latency is bounded by its SLOWEST required replica, not the average one). Achievable write throughput per partition/shard under the current consistency model (directly comparable before/after a consistency-relaxation change). Contention/lock-wait time specifically (for a strongly-consistent single-writer model, how much time writes spend WAITING for a lock or leader slot, a direct signal of how much headroom exists before the coordination bottleneck becomes the limiting factor). Replication lag (for the eventually-consistent path, needed to know the ACTUAL cost being paid in staleness in exchange for the throughput gained, not just a theoretical estimate).
Patterns to move operations to eventual consistency while preserving critical-path correctness. Identify which specific writes are on a genuinely correctness-critical path (the small subset discussed in the checkout/inventory example elsewhere in this topic) versus the majority that aren't, and apply the SAME per-operation consistency-tagging approach as a hybrid-consistency API design: keep the critical subset strongly consistent, move everything else to an eventually-consistent, asynchronously-replicated path. Batch and buffer non-critical writes (accumulate several eventually-consistent writes and apply them together, amortizing coordination overhead, where a strongly-consistent alternative would pay that overhead per-write). Shard more aggressively for the strongly-consistent subset specifically, since sharding reduces per-shard write contention directly, letting you keep strong consistency WITHIN a shard while still scaling overall throughput across shards.
Worked example. A write-heavy service is bottlenecked on a fully strongly-consistent, single-leader-per-shard model, where each write waits for a quorum round-trip before being acknowledged. Profiling shows 90% of writes are low-stakes telemetry-adjacent updates that don't actually need strong consistency (a "last seen" timestamp, an activity counter), only the remaining 10% (account-balance-affecting operations) genuinely need it. For a coordination-bound write path, achievable throughput scales roughly inversely with per-write coordination latency (fewer, shorter waits per write means more writes fit in the same window); moving the 90% onto a locally-accepted, asynchronously-replicated path removes the quorum round-trip from those writes entirely, replacing it with a purely local acknowledgment. The DIRECTION and SHAPE of the win are what's derivable and defensible here (a large, multiplicative throughput increase on the relaxed 90%, since a local write is fundamentally faster than one that waits on a network round-trip to other replicas), the exact multiplier depends on the specific coordination latency and replica topology being replaced, and would need to be measured on the real system rather than assumed. The critical 10% keeps its unchanged strong-consistency guarantee and latency profile throughout, since it was never touched by the change.
A related judgment call: per-tenant consistency in a multi-tenant SaaS product. The same throughput-vs-consistency reasoning applies at the tenant level, not just the operation level: a multi-tenant platform might reasonably guarantee strong consistency for a tenant's configuration changes (critical, low-volume, and where staleness would be confusing and hard to explain support-wise) while running analytics and usage-metrics writes for the same tenants under eventual consistency (high-volume, tolerant of a short delay), the same per-operation-criticality logic from the applied-scenario answers elsewhere in this topic, applied here as the lens for sizing a specific throughput-consistency trade-off decision rather than an unrelated concern.
Trade-offs and pitfalls. A common mistake is measuring throughput improvement without ALSO measuring and monitoring the staleness cost being paid on the relaxed path, a throughput win that's actually causing user-visible staleness problems nobody's watching for isn't a clean win, it's a trade that was made implicitly rather than deliberately and monitored.
Describe an algorithmic approach to reconcile diverged replicas for a key-value store that uses last-writer-wins (LWW) with version vectors. Account for missing timestamps, partial updates, tombstones, and the goal of preserving monotonicity when possible. Explain operational steps an SRE should take to run reconciliation safely.
Sample Answer
Direct answer: Reconciling diverged replicas that use LWW with version vectors means comparing each replica's version vector for a key to determine whether one genuinely dominates the other (safe to just take the dominant one) or they're concurrent (genuine conflict, apply the LWW timestamp rule as the tiebreaker), while explicitly handling missing timestamps, partial updates, and tombstones as special cases that a naive "just compare timestamps" approach would get wrong.
Structured elaboration
Step 1: version-vector comparison first, LWW second. Before falling back to LWW's timestamp comparison, check whether the two replicas' version vectors for the key show a clear happened-before relationship (one vector dominates the other). If so, the dominant version is definitively the correct, more complete one, no ambiguity, no need for LWW at all, this case should never even consult timestamps. LWW timestamp comparison is only the right tool for the GENUINELY concurrent case (vectors incomparable), where there's a real conflict needing SOME resolution rule.
Step 2: missing timestamps. A replica that's never written a key locally may have received it only via replication, and might be missing a proper local timestamp for it (or have one that reflects RECEIPT time, not the original write time), the reconciliation algorithm needs to distinguish "this replica's copy has no meaningful timestamp of its own" from "this replica's copy is genuinely older," treating a missing timestamp as automatically losing (rather than defaulting to some arbitrary sentinel value that might accidentally win or lose incorrectly) is usually the safer default.
Step 3: partial updates. If an update only touched SOME sub-fields of a larger record (not a full replacement), reconciling at the whole-record level with LWW would incorrectly discard sub-field changes that a coarser-grained conflict resolution would drop, the algorithm needs to reconcile at the same GRANULARITY the writes actually happened at (per-field, if updates are per-field), not coarser, or it re-introduces exactly the "discard real information" problem LWW-at-the-wrong-granularity always causes.
Step 4: tombstones. A deleted key isn't simply absent, it needs its own tombstone record (with its own version vector and timestamp) so a reconciliation between a replica that has the delete and one that has a concurrent, unaware UPDATE can correctly determine whether the delete or the update should win (via the same version-vector-then-LWW logic), rather than the delete being invisible to the reconciliation process entirely (which would let a stale update silently "resurrect" a deleted key).
Preserving monotonicity where possible. Where the underlying application semantics allow it (e.g. a monotonically increasing "last known good state" concept), the reconciliation should prefer NOT to move a value backward even when a raw LWW comparison might otherwise suggest it, worth flagging as an explicit design choice (monotonicity as an added constraint layered ON TOP of the version-vector-then-LWW logic, not something either mechanism provides automatically on its own).
Operational steps for running reconciliation safely. Run reconciliation as a controlled, rate-limited background process (not blocking live traffic), sampling or scanning divergent keys via a cheap detection mechanism (checksums or hash comparison per key range) before doing the more expensive full version-vector comparison only on keys that actually show divergence, log every reconciliation decision (which version won, why, for later audit if a resolution turns out to have been wrong), and support a manual override path for cases the automated algorithm can't confidently resolve (e.g. two updates with missing timestamps on both sides, genuinely no safe automatic answer).
Worked example. Replica A has key K with version vector {A:2, B:1}, value "v2", timestamp 100. Replica B has K with version vector {A:1, B:2}, value "v3", timestamp 105. Comparing vectors: {A:2,B:1} vs {A:1,B:2}, neither dominates (A is ahead on its own axis, B on its own), genuinely concurrent. Falls to LWW: B's timestamp (105) is later, B's value "v3" wins. The reconciled version vector becomes the MERGE of both ({A: max(2,1)=2, B: max(1,2)=2}), not just B's original vector, since the resolution needs to reflect that BOTH replicas' prior history has now been accounted for, even though only B's VALUE was kept.
Trade-offs and pitfalls. A common bug is updating the value from the LWW-winning replica but forgetting to merge the version vectors themselves (just copying the winner's vector instead of merging both), which can cause a FUTURE genuinely-new update to be incorrectly classified as "already seen" or vice versa, the value and the causal-history metadata need to be reconciled together, not independently.
Implement a Last-Write-Wins (LWW) merge function for a replicated key-value store in your preferred language (Python or Java). Inputs: two versions of a value each with a timestamp and node id. Return the resolved value and explain how you handle timestamp ties and clock skew. Discuss drawbacks of LWW when applied to counters or sets.
Sample Answer
Direct answer: An LWW merge function compares the timestamps of two candidate versions of a value and returns the one with the later timestamp, breaking exact ties with a secondary, deterministic tiebreaker (commonly the node/replica ID) so that both replicas resolve a genuine tie identically rather than each arbitrarily picking a different winner.
Structured elaboration. The subtlety worth calling out explicitly: a tie on timestamp ALONE isn't safely resolvable arbitrarily, if replica A and replica B both see a tie and each picks differently (e.g. "keep my own local value on a tie"), they'd DIVERGE, exactly what LWW is supposed to prevent. The tiebreaker has to be something both replicas can compute identically and agree on, most simply, comparing node IDs directly.
from dataclasses import dataclass
@dataclass(frozen=True)
class LWWValue:
value: object
timestamp: float # wall-clock or logical timestamp
node_id: str # tiebreaker; must be a stable, comparable, unique ID
def lww_merge(a: LWWValue, b: LWWValue) -> LWWValue:
"""Deterministic: given the same (a, b) pair, always returns the
same winner, regardless of which replica is doing the comparing,
and regardless of argument order."""
if a.timestamp != b.timestamp:
return a if a.timestamp > b.timestamp else b
# Exact timestamp tie: fall back to node_id, a stable, globally
# comparable value both replicas can compute identically.
return a if a.node_id > b.node_id else b
Handling ties and clock skew. The node_id tiebreaker resolves EXACT ties deterministically and symmetrically (lww_merge(a,b) == lww_merge(b,a) always holds, verified by running both orderings on the same pair and confirming identical output). It does NOT resolve clock skew, if replica A's clock is running fast, its writes might incorrectly "win" against replica B's genuinely-more-recent writes simply because A's (skewed) timestamp reads later, this is a real correctness gap, not something the tiebreaker logic can fix, since from the merge function's point of view, a skewed timestamp is indistinguishable from a genuinely later one.
Drawbacks for counters and sets. Applying this SAME LWW merge function to a counter (treating the counter's current total as a single LWWValue) silently drops one of two concurrent increments entirely, rather than correctly summing them, exactly the counter problem discussed elsewhere in this topic, which is why counters need a purpose-built structure (a G-Counter or PN-Counter) rather than a generic LWW register. Applying it to a set (treating the whole set as one LWWValue) has the same shopping-cart-style data-loss problem: one replica's entire set of additions is discarded if the OTHER replica's write happens to have the later timestamp, an OR-Set is the correct structure when both concurrent contributions need to survive.
Worked example. Replica A writes LWWValue(value="blue", timestamp=100.0, node_id="A"). Replica B, concurrently, writes LWWValue(value="green", timestamp=100.0, node_id="B"), an EXACT timestamp tie (a real, if less common, scenario, especially with coarse-grained or synchronized clocks). lww_merge compares timestamps (equal), falls to node_id, "B" > "A" lexicographically, so it returns B's value, "green". Running lww_merge(b_value, a_value) (arguments swapped) gives the identical result, "green", confirming the merge is order-independent, both replicas converge to the same answer regardless of which one happens to see the comparison first.
Trade-offs and pitfalls. Omitting the tiebreaker (or using a non-deterministic one, like "keep whichever value I currently have locally on a tie") is a subtle but real correctness bug: it looks fine in every test that doesn't specifically construct an exact-timestamp tie, and then causes silent, hard-to-reproduce divergence in production the first time two writes genuinely land in the same timestamp bucket, more common than it sounds with millisecond-or-coarser timestamp resolution under real concurrent load.
Outline a design for handling partial commits when a distributed transaction fails mid-way: detection, compensating actions, operator alerts, automated reconciliation, and the runbook steps a support team should follow.
Sample Answer
Direct answer: Handling a distributed transaction that fails partway through means first correctly detecting WHICH steps actually completed (not assuming based on where the failure was reported), then running the compensating actions for exactly those completed steps in reverse order, alerting an operator if any compensation itself fails or the situation is ambiguous, and following a runbook that tells the support team exactly what to check and do rather than relying on improvisation during an incident.
Structured elaboration
Detection. The saga's persisted state (its audit trail of which steps started, completed, or are still pending) is the source of truth for what actually happened, not an assumption based on where an error was thrown. A step that "failed" from the caller's point of view (timeout, exception) might have actually succeeded server-side; before compensating, check the step's actual outcome via a status lookup where the downstream system supports one, rather than assuming failure and risking a compensation that undoes something that was never actually applied and is also currently unconfirmed.
Compensating actions, in reverse order. Once you know which steps genuinely completed, trigger their compensating actions starting from the most recently completed step and working backward, this mirrors the forward order and avoids a compensation depending on a later step's data that's already been compensated out from under it.
Operator alerts. Any partial-commit situation that reaches this point (a saga step failed mid-flight) should generate an alert, not require a human to notice it independently, with enough context (saga ID, which step failed, what compensations are being attempted) that an on-call engineer doesn't have to dig through logs from scratch to understand what's happening.
Automated reconciliation. For failure classes that are well-understood and have a known-safe automated fix (e.g. "release an inventory hold that's stuck because the downstream payment step definitively failed"), the compensating action should run automatically rather than waiting for a human, reserving human involvement for genuinely ambiguous or high-stakes cases (compensation itself failing repeatedly, or a situation the automated logic doesn't have a defined response for).
Runbook steps for the support team. A concrete, written procedure: (1) look up the saga by ID or affected customer/order, (2) check its current state and which compensations have run, (3) if a compensation itself has failed, follow the documented manual-fix procedure for that SPECIFIC step (not a generic "figure it out"), (4) confirm resolution and update the saga's state to reflect the manual intervention, (5) if this is a recurring pattern, file it for the automated-remediation backlog rather than treating every occurrence as a one-off.
Worked example. A saga reaches step 3 (charge payment) which times out. The orchestrator checks the payment gateway's status API for this transaction ID (detection, not assumption) and gets back "declined", a definitive, confirmed failure. It triggers compensation for steps 1-2 (release inventory hold, cancel the pending order) in reverse order, logs the outcome, and moves on, no human involvement needed since this is a well-understood, automatable case. In a different incident, the compensating "release inventory hold" call itself fails three times (the inventory service is having an outage), the orchestrator alerts on-call with the saga ID and the specific failing compensation, and the runbook's documented manual procedure for "inventory service unreachable during compensation" is to manually flag the hold for release once the service recovers, verified against a checklist, rather than leaving it silently stuck.
Trade-offs and pitfalls. Compensating based on an ASSUMED failure (rather than a confirmed one) when the downstream system actually succeeded is a common way this goes wrong in practice, it's worth explicitly building the "check actual status before compensating" step into the design rather than treating a timeout as automatically equivalent to a failure.
Unlock Full Question Bank
Get access to all Data Consistency and Distributed Transactions interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.