Caching Strategies and Distributed Caching Questions
Using caches to reduce latency and load: cache-aside, read-through, write-through, and write-behind patterns, TTLs, eviction policies, and distributed caches such as Redis or Memcached. Covers cache invalidation, stampede and thundering-herd protection, and the consistency tradeoffs of caching. Focuses on where and how to cache across tiers.
Design a cache key naming and versioning strategy for a microservice whose response schema changes frequently. Explain how you would support rolling upgrades, avoid stale data breaking clients, and manage key explosion over time with examples of key formats and migration steps.
Sample Answer
Direct answer
Embed a namespace version in the cache key itself (product_v42:123) so a bulk invalidation is a single atomic version bump rather than deleting millions of individual keys, and old versions simply age out via normal eviction instead of needing an active cleanup pass.
Structured elaboration
- How it works: reads and writes always go through a lookup of "what is the current namespace version" (cached itself, refreshed rarely) and construct the key as
entity_v{version}:{id}; when a schema or bulk change happens, bump the version atomically in one place, and every subsequent read/write automatically targets the new namespace, with old-version keys becoming unreachable (not actively deleted, just no longer referenced). - Atomic bulk invalidation via version bump: this is the core benefit; rather than issuing a delete for every affected key (expensive, slow, and racy if new writes happen during the delete), one atomic increment of a version counter instantly redirects all future reads/writes to the new namespace.
- Garbage-collecting old namespaces: because old-version keys are simply unreferenced, not deleted, they occupy memory until evicted normally by the cache's eviction policy (least-recently-used or time-to-live based); if faster reclamation is needed, a background sweep can explicitly delete keys matching the old version prefix, but this is an optimization, not a correctness requirement.
- Single-point-of-failure concerns for the version pointer: the "current version" lookup itself must be fast, highly available, and consistent, since every cache operation depends on it; storing it in the same cache cluster (as a well-known key) is common, but that makes the version pointer's own availability a dependency for the whole cache, worth explicitly designing for (e.g., a short local cache of the version value with a brief TTL, so a momentary version-lookup failure does not stall every request).
- Compare-and-swap (CAS) on versioned keys: for concurrent updates to the same entity, a versioned key naturally supports CAS semantics (only apply if the version you are updating still matches what you read), avoiding a stale-overwrite race without needing an explicit lock.
Worked example
A microservice's response schema changes in a way that would make previously-cached responses invalid; rather than deleting potentially millions of cached response keys (slow, and racy against concurrent writes happening during the delete), bumping the namespace version from v41 to v42 in one atomic operation instantly means every read constructs a v42 key, gets a miss, and repopulates with the new schema, while old v41 entries simply age out of the cache over the following minutes to hours via normal eviction.
Trade-offs and pitfalls
If the version pointer lookup becomes slow or unavailable, every cache operation that depends on it is affected, so treat its own latency and availability as a first-class design concern, not an afterthought. Not garbage-collecting old-version keys at all (relying purely on eviction) can leave a meaningful amount of dead memory around if the cache is large and eviction pressure is low; monitor and, if needed, add an explicit sweep for versions old enough to be safely deleted.
For a multi-tenant platform using Redis as a shared caching layer, propose a secure architecture: cover access control, encryption in transit and at rest, tenant key isolation, key discovery and least-privilege, detection of key leakage, and an operational runbook for key compromise. Discuss performance implications.
Sample Answer
Direct answer
Securing a shared multi-tenant cache means treating it like any other multi-tenant datastore: authenticate and authorize every client, encrypt data in transit and at rest, isolate tenants' keys from each other, and have a plan for detecting and responding to a key leak or compromise.
Structured elaboration
- Access control: use Redis access-control lists (ACLs) or an equivalent mechanism to give each service or tenant the minimum permissions it needs (read-only where possible, scoped to its own key prefix), rather than a single shared credential with full access for every client.
- Encryption in transit and at rest: Transport Layer Security (TLS) between clients and the cache prevents network-level eavesdropping; encryption at rest (or encrypting sensitive fields before storing them) protects against a compromised disk or snapshot backup being readable.
- Tenant key isolation: three common patterns, each with different trade-offs: key prefixes (simplest, but relies entirely on application-level discipline to never cross prefixes), logical databases (Redis's numbered databases, a bit more isolation but still shared infrastructure), and separate clusters per tenant (strongest isolation, highest operational cost). Choose based on how sensitive the data is and how much a cross-tenant leak would cost you.
- Least-privilege key discovery: a compromised client credential should only be able to see or affect its own tenant's keys, never enumerate or read across tenants; this is what ACLs scoped to key prefixes are for.
- Detecting key leakage: monitor for access patterns that look like enumeration (a client rapidly scanning many keys outside its normal pattern) or access from an unexpected tenant's credentials to another tenant's prefix.
- Operational runbook for key compromise: rotate the compromised credential immediately, audit what that credential accessed during the suspected compromise window, and assess whether any cached sensitive data needs to be purged or whether downstream systems need notification.
Worked example
A platform serving hundreds of tenants through one Redis cluster uses key prefixes (tenant:{id}:...) plus per-tenant ACL rules restricting each service credential to ~tenant:{id}:* patterns only; a compromised credential for one tenant therefore cannot read or write any other tenant's keys, bounding the blast radius of that specific credential's compromise to one tenant's data.
Trade-offs and pitfalls
Key prefixes alone, without ACL enforcement, are a convention, not a security boundary; a bug or a malicious client can simply read any prefix if nothing actually enforces the restriction. Performance implications of encryption (TLS overhead, at-rest encryption/decryption cost) are usually small relative to network and compute costs elsewhere, but should be measured, not assumed, especially for very high-throughput, low-latency use cases.
A payments ledger requires strong correctness when updating balances. Compare write-through caching (synchronous write to cache and datastore) vs write-behind (asynchronous background writes). For each, discuss durability, read visibility immediately after write, failure modes, and techniques (idempotency, ordering) to preserve correctness. Which approach would you choose and why?
Sample Answer
Direct answer
For a durability-sensitive service like payments, prefer write-through (synchronous write to both cache and datastore) or cache-aside with synchronous invalidation over write-behind, because write-behind's asynchronous flush introduces a data-loss window that is unacceptable when the data is money or an authentication state.
Structured elaboration
- Write-through for payments: every write goes to the cache and the datastore together, synchronously, before acknowledging the caller; reads are always fresh, and there is no window where an acknowledged write could be lost, at the cost of higher write latency (paying for both writes on the request path).
- Cache-aside with synchronous invalidation for sessions: a session service that must reflect login/logout changes quickly can use cache-aside (only cache what is actually read) with an invalidation triggered synchronously on logout, rather than waiting for a time-to-live (TTL) to expire; this balances the simplicity of cache-aside with the responsiveness a security-sensitive change needs.
- Why write-behind is the wrong default here: write-behind acknowledges the write before it is durably applied to the datastore; a crash in that window loses the write entirely, which is an acceptable trade for a metrics counter and not acceptable for a payment or a security-relevant state change.
- Trade-off in consistency versus write amplification: write-through pays extra write latency and writes data that might never be read (caching every write regardless of read demand); cache-aside with synchronous invalidation only caches what is actually requested, trading a small amount of extra invalidation-path complexity for less wasted cache capacity.
- Failure modes to consider: with write-through, a failure writing to EITHER the cache or the datastore must be handled explicitly (does the whole operation fail, or does it proceed with just the datastore write and treat the cache write as best-effort); an inconsistent partial failure here is exactly the kind of subtle bug that durability-sensitive systems cannot tolerate silently.
Worked example
A session service where sessions must invalidate within seconds of logout: cache-aside with an explicit, synchronous invalidation call on logout (rather than relying on a TTL to eventually expire the session) meets that requirement directly; a subsequent read after logout misses the cache, goes to the datastore, finds the session revoked, and correctly denies access, all within the same request cycle rather than waiting out a TTL window during which a logged-out session token would still work.
Trade-offs and pitfalls
Choosing write-behind for its throughput benefit on a durability-sensitive path is a common and dangerous shortcut; always name the data-loss window explicitly and get an explicit sign-off that it is acceptable before using it, rather than defaulting to it for performance reasons alone. A write-through implementation that treats the cache write as best-effort (silently swallowing cache write failures) can mask a growing coherence problem between cache and datastore; log and monitor cache write failures even when they are non-fatal to the request.
Write pseudocode (or Python) for a cache-aside get operation that implements request coalescing: when multiple concurrent requests miss on the same key, only one request fetches from origin while others wait for the cached result. Include timeout and a fallback to origin if the fetch fails. Explain how you avoid deadlocks and unbounded waiting.
Sample Answer
Approach
On a cache miss, try to atomically acquire a short-lived per-key lock; whichever request wins the lock does the origin fetch and populates the cache, then releases the lock. Requests that lose the race wait briefly and re-check the cache, falling back to a direct (uncoalesced) fetch if they exceed a bounded wait, so a crashed lock-holder cannot stall everyone else indefinitely.
import time
import json
import redis
LOCK_TTL_MS = 5000
POLL_INTERVAL_S = 0.05
MAX_WAIT_S = 2.0
def cache_aside_get(r: redis.Redis, key: str, origin_fetch, ttl_seconds: int):
cached = r.get(key)
if cached is not None:
return json.loads(cached)
lock_key = f"lock:{key}"
got_lock = r.set(lock_key, "1", nx=True, px=LOCK_TTL_MS)
if got_lock:
try:
value = origin_fetch()
r.set(key, json.dumps(value), ex=ttl_seconds)
return value
finally:
r.delete(lock_key)
# Someone else is refreshing this key; wait briefly for them to finish.
deadline = time.monotonic() + MAX_WAIT_S
while time.monotonic() < deadline:
cached = r.get(key)
if cached is not None:
return json.loads(cached)
time.sleep(POLL_INTERVAL_S)
# Timed out waiting: fall back to a direct fetch rather than blocking forever.
return origin_fetch()
Key points
The lock has a time-to-live (TTL), implemented as px=LOCK_TTL_MS, independent of whether the holder releases it cleanly, so a crash mid-fetch self-heals after the TTL expires instead of deadlocking every future request for that key. Waiters poll the cache, not the lock, because what they actually want is the result, and polling the lock would require a second round trip once it is released.
Complexity
Best case (cache hit): O(1), a single Redis read. Cache miss and lock acquired: O(1) Redis operations plus the cost of origin_fetch. Cache miss and lock lost: O(MAX_WAIT_S / POLL_INTERVAL_S) Redis reads while waiting, bounded and small (at the settings above, at most 40 polls).
Timeout and deadlock considerations
The lock's px (millisecond TTL) must comfortably exceed the expected origin_fetch duration, or the lock will expire while the legitimate holder is still working, letting a second request also acquire it and defeating coalescing (a correctness-degrading but not a safety-violating outcome: at worst you get two concurrent fetches instead of one, not corrupted data). The waiter's MAX_WAIT_S bounds how long a request will wait before giving up and fetching directly, which is the deadlock-avoidance mechanism; a waiter should never wait indefinitely.
Edge cases
origin_fetch raising an exception must still release the lock (handled here with finally), or every subsequent request for that key would wait out the full lock TTL unnecessarily. Two requests briefly both believing they hold the lock (if the TTL was set too short relative to fetch time) results in a redundant fetch, not corrupted state, since the last successful SET on the cache key simply wins; document that as an accepted, bounded degradation rather than treating it as a bug to eliminate entirely.
How would you design caching for large binary or JSON objects larger than 1MB that need to be served with low latency? Discuss the trade-offs of your approach, including memory-fragmentation considerations, and how to balance latency versus cost.
Sample Answer
Direct answer
For objects larger than about 1MB (megabyte), caching the object's bytes directly in a general-purpose in-memory cache is usually the wrong default; instead, offload the bytes to object storage and cache only a reference (and small, frequently-needed metadata), reserving direct in-memory caching for cases where the object is both large AND accessed with very low latency requirements that object storage cannot meet.
Structured elaboration
- Chunking: for objects that are read partially (a video seek, a large document's specific section), splitting into smaller chunks lets you cache only the actively-accessed portions rather than the whole object, and lets a partial cache hit still provide value.
- Compression: reduces both the memory footprint per cached object and network transfer time, at the cost of CPU for compress/decompress; worth it when memory or network bandwidth is the tighter constraint relative to available CPU.
- Offloading to object storage with cached references: store the actual bytes in a system built for large-object storage (durable, cheap per gigabyte) and cache only a reference (a URL or storage key) plus small metadata (size, content type, a short-lived signed access token if needed); this keeps the fast in-memory cache's precious capacity for many small, hot items rather than a few large ones crowding it out.
- Memory fragmentation considerations: storing objects of widely varying sizes in the same cache (a mix of small config values and occasional large blobs) can cause memory fragmentation in some cache implementations, reducing effective usable capacity below the raw configured limit; segregating large objects into a separate cache instance or tier, sized and tuned differently, avoids this.
- Balancing latency versus cost: caching large object bytes directly in memory gives the lowest possible latency but at high memory cost per item (crowding out many smaller, possibly more valuable cached items); offloading to object storage with a content delivery network (CDN) in front trades a small amount of latency (still fast, just not "already in local memory" fast) for much lower cost per byte and no fragmentation risk to the primary cache.
Worked example
A service serving large generated reports (5 to 50 MB each): rather than caching the full report bytes in the same Redis instance used for small, frequently-accessed session and configuration data, generated reports are written to object storage with a content-addressed key, and only that key (plus small metadata) is cached in Redis; clients fetch the actual report bytes from object storage (behind a CDN for repeat access) using the cached reference, keeping Redis's memory dedicated to the many small, hot items it is well-suited for.
Trade-offs and pitfalls
Caching large object bytes directly "because it's simple" can quietly degrade the whole cache's effectiveness for everything else sharing that cache instance, by consuming a disproportionate share of memory and contributing to fragmentation; segregate large and small objects into different tiers by default rather than only after noticing a problem. Offloading to object storage adds a small amount of latency and a second system to operate (object storage plus, often, a CDN in front of it); this is the right trade for genuinely large objects, but do not apply it reflexively to moderately-sized objects where direct in-memory caching remains the simpler, faster choice.
Unlock Full Question Bank
Get access to all Caching Strategies and Distributed Caching interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.