System Design · Distributed Data
Once one machine is not enough, you copy your data across several. The moment you do, you inherit three hard questions — is every copy correct, is the system always answering, and does it survive a broken network? This note is how to reason about that trade, keep replicas in sync, and put a cache in front so the database stops drowning.
In any system where servers talk over a network, a break in that network forces a choice: keep answering with possibly-stale data (availability) or refuse to answer until every copy agrees (consistency). You cannot have both while staying partition-tolerant — so every replication and caching decision downstream is really you picking a side of that trade for your specific use case.
Hand-copy this. Dense and telegraphic; every line expands in the Detailed Notes below.
| Type | Rule | Example |
|---|---|---|
| Strong | every read = latest | bank balance, payment |
| Eventual | replicas differ briefly, converge later | IG comments, reviews |
| Mode | Master waits for | Trade |
|---|---|---|
| Async | nothing — replica reads logs later | fast, risk data loss |
| Sync | ALL replicas ack | strong C, high latency |
| Semi-sync | ≥1 replica ack | balanced (common) |
| Strategy | How | Best for |
|---|---|---|
| Cache-aside | app checks cache; miss → DB → fill | read-heavy (default) |
| Read-through | app talks to cache only; cache loads DB | abstracted reads |
| Write-through | write cache + DB together | consistency |
| Write-back | write cache now, DB later async | write-heavy (risky) |
| Feature | Redis | Memcached |
|---|---|---|
| Data types | rich structures | key–value only |
| Persistence | yes (snapshot→disk) | no |
| Scale / replication | horizontal | no |
| Also does | sessions, rate-limit, pub/sub, queue | simple cache |
Picture a bank running on one server. It works — until that server dies, and every customer is locked out of their money at once. So you add a second server with a copy of the data, and a third. Now nobody is locked out by a single failure. But you have quietly created a new problem: you deposited ₹5,000 on server one, and a moment later someone checks their balance on server two, which has not heard the news yet. Which number is correct — and which do you show? Every idea in this note grows out of that one uncomfortable moment.
The CAP theorem says a distributed system can guarantee at most two of three properties at once: consistency, availability, and partition tolerance. "Distributed" just means your data lives on more than one machine that must talk over a network. Once that is true, CAP tells you that you cannot escape a trade-off — you only get to choose which pain you accept.
Let us define the three plainly before using the jargon.
Every read returns the newest data, no matter which server you hit. Transfer ₹5,000 out of ₹20,000 and every server must now say ₹15,000 — none may still say ₹20,000.
Every request gets a response, even if the data is slightly old. Upload a photo and some followers see it instantly, others after a few seconds — but the app never stops responding.
Servers keep working even when the network between them fails — an outage, a data-centre failure, a cloud networking issue. The system does not collapse.
Think of two shop tills that share one price book by walkie-talkie. Consistency is both tills always quoting the same price. Availability is each till always serving a customer. Partition tolerance is the walkie-talkie dying — and when it does, each till must choose: keep selling at a possibly-old price (A), or stop selling until the radio is back (C). It cannot do both.
Here is the part people miss. In a genuine distributed system, partitions are not optional — networks fail, and you must tolerate that. So P is effectively mandatory, and the real, live decision is always consistency versus availability. That reframing is the whole theorem.
The third pairing, CA, only exists where there is no network to partition — a single server with a standalone database. It is consistent and available because there is nothing to fall out of sync. But it is not really a distributed system, and it carries a single point of failure: if that one machine dies, everything dies. It also cannot scale out. So in interviews, CA is the "technically true, practically irrelevant" corner.
"Rejecting a request until consistent" is exactly what makes a CP system unavailable in that window — people wrongly assume consistency and availability are independent knobs. They are not: under a partition, buying more of one spends the other. Say this trade out loud in an interview; it is the point of the whole theorem.
Interviewers rarely ask "what is CAP?" directly. They ask you to design something, then probe your database and consistency choices. Naming your CAP stance unprompted — "this is payments, so I'll take CP and accept rejecting writes during a partition" — signals seniority. Always justify the pick against the requirement; a contradictory justification (choosing availability for a bank) is the tell of a weak answer.
Strong consistency means every read returns the latest write; eventual consistency means replicas may briefly disagree, then converge. The word "eventually" is doing real work — it names the gap between "master updated" and "all replicas caught up".
Go back to the two-server bank. You write ₹50 to a variable on the master (it was ₹40). The master now says 50. The replica still says 40 until the change is copied over. If a read lands on the replica during that window, it sees 40 — stale. A CP system refuses that read until the replica catches up; an AP system serves the 40 and lets you catch up later. That copy lag — maybe two or three minutes in a bad case — is the "eventual" in eventual consistency.
Eventual consistency is a group chat where one person's message is still "sending". Everyone will see it soon; right now, different phones show different states. Nobody is broken — the system just has not finished propagating.
| Property | Strong | Eventual |
|---|---|---|
| Read guarantee | Always latest value | Maybe stale, converges later |
| CAP leaning | CP | AP |
| Cost | Higher latency | Temporary staleness |
| Examples | Bank balance, online payment | Instagram comments, product reviews |
One clarification that resolves a lot of confusion: what actually needs replicating is the data, i.e. the database. Your backend services are just copies of the same application code — deploy the same container image to four servers and they are interchangeable, holding no unique state. The database is the shared source of truth, so keeping replicas in agreement is a database responsibility, not something your application logic handles. [+] This is also why locking (below) lives in the DB.
Replication is keeping copies of your database (and services) on multiple machines so no single failure takes you down. You designate one copy as the master (or primary), which takes writes, and one or more replicas (or slaves) that mirror it. The interesting question is how the master tells the replicas about a new write — and how long it waits for them.
The core mechanism is the same across modes: when the master commits a write, it records that committed transaction in a log. Replicas continuously tail that log, spot the new commit, and replay the identical operation on themselves. A commit cannot be rolled back, so replaying it is safe. The three modes differ only in when the master considers the write "done" for the client.
The master commits and replies to the client immediately, without waiting for any replica. Replicas catch up later by reading the log. Fast for the client, but if the master dies before a replica has caught up, those writes are lost. This is the common default.
The master writes locally but does not commit or reply yet. It pushes the write to the replicas and waits for all of them to acknowledge they have saved it. Only then does the master finalise its own commit and return success to the client. This gives strong consistency — the client is not told "done" until every copy agrees — at the cost of latency, since the slowest replica sets the pace.
If a replica is down under synchronous replication, the master waits for an acknowledgement that never comes. After a configured timeout it fails the whole write — the ₹5,000 is not saved even on the master. Beginners assume "at least the master keeps it"; it does not, because sync mode's promise is all-or-nothing across every replica.
The middle path, and the one most real systems pick. The master writes locally and pushes to all replicas, but waits for only at least one acknowledgement before confirming success to the client. The remaining replicas sync asynchronously afterwards. This cuts the risk of data loss (the write survives on at least two machines) without paying the full latency of waiting for everyone.
The quiet payoff of semi-sync: because at least one replica is guaranteed to have the write, if the master crashes you can promote that replica to master with zero data loss. That is why semi-sync is the sensible default — you get most of sync's safety and most of async's speed, plus a clean failover story. Reach for it when someone asks "what if the primary dies?"
| Mode | Master waits for | Consistency | Latency | Data-loss risk |
|---|---|---|---|---|
| Async | Nothing | Eventual | Lowest | Highest |
| Sync | All replicas | Strong | Highest | Lowest |
| Semi-sync | ≥ 1 replica | Near-strong | Medium | Low |
All three modes answer one question: how many copies must confirm before I say "done"? None = async, one = semi-sync, all = sync. Memorise the question and you can re-derive the whole table on the spot. And note there is no "perfect" mode — every option is a trade-off, which is the entire spirit of distributed systems. Interviewers aren't after the "right" answer; they want you to justify the pick against the system's needs (a payment flow leans sync or semi-sync; a like-counter is happy with async).
All of this — the read locks and write locks that make it safe, the row-level locking that keeps performance high — happens inside the database, not your application. The DB owns the ACID properties (atomicity, consistency, isolation, durability); your service just reads and writes and lets the DB decide which locks to take. A common interview stumble is claiming you manage locks in application code. You do not.
A cache stores frequently used data in fast memory (RAM) so repeated requests skip the slow trip to the database. Imagine 10,000 people opening the same product page in a minute. Without a cache, that is 10,000 identical read queries hammering your database. With one, the first request fills the cache and the other 9,999 are served straight from memory — the database sees roughly one query.
A cache is the notepad on your desk versus the filing cabinet down the hall. The first time you need a figure you walk to the cabinet (the database) and copy it onto the notepad (the cache). Every time after, you just glance at the notepad. The trade is obvious: the notepad is tiny, and if the figure changes in the cabinet, your note goes stale.
The read path has two outcomes. A cache hit means the data was in the cache — returned fast, straight from RAM. A cache miss means it was not: the app reads the database, stores the result in the cache, and returns it, so the next identical request will hit. The payoffs are faster responses, lower latency, less database load, and better scalability.
A dedicated fast store like Redis. The workhorse of backend caching.
Images, CSS, JavaScript kept locally so revisits load without re-fetching.
Stores results of frequently-run queries so the DB skips recomputing them.
Static content (images, video, CSS, JS) served from the server physically closest to the client, cutting network latency. e.g. Cloudflare, Akamai, CloudFront.
What a cache stores is flexible — most commonly serialized objects, but also JSON strings, images, whole HTML pages, or full API responses. In an interview, don't over-claim ("a cache always stores objects"); say it holds whatever representation lets you skip the expensive work, usually a serialized object.
What you cache matters as much as how. Caching pays off best on mostly-static data — profiles, product information, configuration — where a value stays valid long enough to be reused. For data that changes constantly you don't abandon the cache, you shorten the TTL and invalidate on write.
RAM is small, so a cache cannot keep everything. Three mechanisms manage that scarce space.
Eviction decides what to drop when the cache is full. LRU (least recently used) drops whatever has not been touched in the longest time — it keeps hot data around. LFU (least frequently used) drops whatever has been accessed the fewest times. FIFO drops the oldest entry regardless of use — simple, but weaker than LRU.
Expiration (TTL, time-to-live) stamps each entry with a lifespan. A user profile might live 10 minutes, a product list 30. When the TTL expires, the entry is removed and the next request refetches from the database. TTL stops the cache serving something forever-stale.
Invalidation handles the case where the underlying data changes before its TTL is up: when you write to the database, you also delete or update the matching cache entry so nobody reads the old value.
TTL is a two-sided trade, and interviewers love pushing on it. A short TTL means fresher data but more database hits (you refetch often). A long TTL means fewer hits but a bigger window to serve stale data after a write. There is no "correct" TTL — you tune it to how tolerant the data is of staleness, and you should say so rather than picking a number blindly.
These describe who talks to what, and in what order on reads and writes.
App looks in cache; on a miss it reads the DB, fills the cache, returns. Simple, memory-efficient. Downside: the first request for any key is always a slow miss.
App only ever talks to the cache. On a miss, the cache itself loads from the DB and returns. The app never touches the database directly.
Every write goes to cache and DB at the same time. Great for consistency — the cache is never stale. Downside: writes are slower (two stores per write).
Write to cache immediately, flush to DB asynchronously. Fast for write-heavy loads. Downside: if the cache crashes before the flush, the newest writes are gone.
Redis is an in-memory database, most often used as the cache in system design. Because it keeps data in RAM, reads and writes are extremely fast — far faster than a disk-based store. RAM's weakness is that it is volatile: lose power and you lose the data. Redis answers that with persistence — periodically it takes a snapshot of its in-memory state and writes it to disk, so after a crash or restart it can reload. You get RAM speed and disk-backed recovery.
Redis persistence is autosave in a document editor. You work at memory speed on the live document; every few minutes it quietly saves a copy to disk. Crash, and you reopen from the last save — you lose seconds, not the whole document.
Beyond caching, Redis is horizontally scalable (run many instances across servers), supports rich data structures rather than plain key–value, and doubles as session storage, a rate limiter, a message queue, and a publish/subscribe bus. Memcached, by contrast, is a lightweight key–value cache: simpler and fine for basic lookups, but no persistence, no replication, no rich types.
| Feature | Redis | Memcached |
|---|---|---|
| Data types | Rich structures | Key–value only |
| Persistence | Yes (snapshot to disk) | No |
| Replication / scale | Horizontal | No |
| Transactions | Yes | No |
| Pub/sub | Yes | No |
| Memory footprint | Higher | Lower |
The instructor's scripted answer for interviews: name Memcached first, grant it one or two honest merits (simpler, lighter), then pivot — "but I'd choose Redis for its persistence, horizontal scalability, and richer data types." Showing you know the alternative and then justifying your pick reads far stronger than only ever naming Redis.
The second habit from the session, and the more valuable one: drop concept names on purpose to bait the interviewer's next question. Say "I'd put a cache here to keep read latency low" and you have planted a thread — the natural follow-up is about eviction, TTL, or Redis, all ground you have prepared. It works like mentioning you love cricket so people ask you about cricket. You are not dodging the interview; you are steering it toward your strengths.
Because a network partition will eventually happen, so P is forced on you. When the network splits, servers can't sync, and you must choose per request: answer with possibly-stale data (sacrifice C) or refuse to answer until data agrees (sacrifice A). You can't do both simultaneously during the split, so at most two of the three hold.
CP: a banking transfer — showing a wrong balance is unacceptable, so during a partition you'd rather reject the request than return stale money. AP: an Instagram feed — a follower seeing a photo a few seconds late is fine, and the app going down is not, so you serve stale and reconcile later. The rule is: does wrong-but-fast beat right-but-refused for this data?
It means replicas can hold different values briefly, then converge to the same latest value. The gap is the replication lag — the time to copy the committed change from master to replica. A read during that window may return stale data; a read after it returns the latest. It's the model behind most AP systems.
Async: master replies immediately, replicas catch up via the log later — fastest, but writes can be lost if the master dies first. Sync: master waits for all replicas to ack before committing — strong consistency, highest latency, and a down replica fails the write. Semi-sync: master waits for at least one ack — balanced risk and latency, and the ack'd replica can be promoted on failover.
The master waits for an acknowledgement it will never receive. After a configured timeout it fails the entire write and returns an error to the client — the value is not saved even on the master, because sync mode requires all replicas to confirm. That's the availability cost of strong consistency.
The database. It manages read locks, write locks, and fine-grained row-level locks as part of enforcing ACID. Row-level locking (rather than locking the whole table) is what keeps performance up: only the row being written is locked, so other rows stay readable and writable. Your application just issues reads and writes and never manages locks itself.
Write-back suits write-heavy workloads where you can't afford to pay a DB write on every request — you write to cache instantly and flush to the DB asynchronously. The cost is durability: if the cache crashes before flushing, the newest writes are lost. Write-through is the opposite choice — slower writes, but the cache and DB never disagree, so pick it when consistency matters more than write throughput.
A short TTL expires entries quickly, so you refetch from the DB often — fresher data, more database load. A long TTL keeps entries around — fewer DB hits, but a wider window in which a post-write change isn't reflected, so more stale reads. You tune TTL to how stale the data is allowed to be, and pair it with invalidation-on-write for data that changes unpredictably.
Redis keeps RAM-speed reads/writes but adds persistence via periodic disk snapshots, so a crash or restart doesn't wipe the data. It's also horizontally scalable, supports rich data types and transactions, and can serve as a session store, rate limiter, and pub/sub bus. Memcached is simpler and lighter but is key–value only with no persistence or replication — fine for basic caching, weaker as a general building block.
The master records every committed transaction in an append-only log. Each replica continuously tails that log, spots commits it has not applied, and replays the identical operations against its own copy. Nothing in the application is involved — the replica is not "told" it is stale so much as it is always reading forward from wherever it got to. This is why the mechanism is the same for async, sync, and semi-sync: only the master's willingness to wait changes.
Reading proves you recognise these ideas. These prove you can use them. Attempt each before opening the walkthrough.
A payments team wants a write to be lost never, even if the primary machine dies right after committing. They've configured replication as below. Predict what happens on a normal write, then on a write where one replica is temporarily down, and say whether this config actually meets their "never lose a committed write" goal.
replication:
mode: synchronous # master waits for replica acknowledgements
wait_for: all # every replica must ack before commit
ack_timeout_seconds: 30 # then give up
on_timeout: fail_write # return an error to the client
Step 1 — Trace the healthy path. In synchronous mode the master writes locally but does not commit yet. It pushes the write to all replicas and waits. When every replica acknowledges, the master finalises its commit and returns success. So a healthy write succeeds — with latency set by the slowest replica.
Step 2 — Trace the degraded path. With one replica down, its acknowledgement never
arrives. Because wait_for: all, the master keeps waiting until
ack_timeout_seconds elapses, then on_timeout: fail_write triggers. The
write fails entirely — it is not even kept on the master. The client gets an error.
Root cause / verdict. The goal is "never lose a committed write." This config never loses a committed write — but it achieves that partly by refusing to commit at all whenever any replica is down, which is a severe availability hit. Worse, requiring all replicas makes every replica a single point of failure for writes. The safer fit is semi-synchronous: wait for at least one replica ack, so the write survives on ≥2 machines and an ack'd replica can be promoted if the master dies — without one sick replica halting all writes.
replication:
mode: semi_synchronous # wait for at least one replica
wait_for: 1 # >= 1 ack, not all
ack_timeout_seconds: 30
on_timeout: fail_write # still fail rather than lose durability
# remaining replicas sync asynchronously afterwards;
# any ack'd replica is promotable on primary failure
Why. Sync's "all replicas" promise buys strong consistency at the price of availability and a fragile write path; semi-sync keeps the no-data-loss + promotable-failover guarantee the payments team actually needs, without letting one down replica block every write.
Write a cache-aside read function get_product(id) against an in-memory store (a Redis
stand-in) and a database. Follow the cache-aside contract exactly.
cache = {} # stand-in for Redis: key -> value (TTL handled by set_with_ttl)
db = {"p1": {"name": "Desk Lamp", "price": 1200}}
def set_with_ttl(key, value, ttl):
cache[key] = value # assume TTL expiry is handled for you
def db_read(id):
return db.get(id) # returns the row dict, or None
def get_product(id):
# TODO: implement cache-aside
...
Step 1 — Check the cache first. Cache-aside means the application owns the
lookup order. Look for the key in the cache. If it's present, that's a hit — return it immediately
and never call db_read.
Step 2 — Handle the miss. If the key isn't cached, read the database. If the DB has
no row, return None (don't cache a miss here). Otherwise store the row with the
required 300s TTL, then return it — so the next identical call hits.
Step 3 — Verify the contract. First get_product("p1"): not in cache →
DB read → fill → return. Second call: found in cache → return with no DB read. That matches the
expected hit/miss pattern.
def get_product(id):
if id in cache: # cache hit
return cache[id]
row = db_read(id) # cache miss -> go to DB
if row is None:
return None
set_with_ttl(id, row, 300) # fill cache for next time
return row
Why. The app checking the cache before the DB, and populating only on a miss,
is cache-aside (lazy loading). The 300s TTL is your staleness bound; pair it with
invalidation on write if p1's price can change before the TTL expires.