Caching Strategies and In Memory Storage Questions
Caching strategies for improving performance and reducing latency: HTTP caching semantics (Cache-Control, ETag, conditional requests, Vary), application-level caching with Redis and Memcached, in-memory data structures for caching, cache eviction policies (LRU, LFU, FIFO), cache invalidation strategies, TTL selection and trade-offs, and the consistency and performance implications of deciding what and when to cache.
MediumTechnical
49 practiced
Implement a thread-safe memoization decorator in Python that caches function results with a TTL per key. Explain how you handle race conditions when multiple callers compute the same expired value, and provide example usage demonstrating caching and expiry behavior.
Sample Answer
To implement a thread-safe memoization decorator with TTL per key, we keep a dict of cache entries and protect modifications with a global lock, but avoid holding it while doing the expensive computation. For concurrent callers requesting the same key when the value is missing or expired, the first caller becomes the "producer" and others wait on an Event in the cache entry so only one computation runs.Key points:- Use a global lock to safely insert/read entries, but release it before running fn to avoid blocking other keys.- Each entry carries an Event so other threads can wait for the in-progress computation.- Only one thread computes per key (prevents thundering herd and duplicate work); others wait and read the completed value.- Edge cases: if fn raises, ensure event is set and entry may hold an exception or be removed; you might want to store exceptions and re-raise for waiters. Also consider cache size eviction, argument hashing for unhashable args, and clock skew if system time changes.Complexity:- Overhead: O(1) average lookup; memory O(number of cached keys).- Threads waiting cost minimal (blocked on Event).
python
import threading
import time
from functools import wraps
from typing import Any, Callable, Dict, Tuple
class _Entry:
def __init__(self):
self.value = None
self.expiry = 0.0
self.event = threading.Event() # signals completion of computation
def ttl_cache(ttl_seconds: float):
def decorator(fn: Callable):
cache: Dict[Tuple[Any,...], _Entry] = {}
cache_lock = threading.Lock()
@wraps(fn)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items()))) if kwargs else (args, ())
now = time.time()
with cache_lock:
entry = cache.get(key)
if entry and entry.expiry > now:
return entry.value
if entry and not entry.event.is_set():
# Another thread is computing; wait below after releasing lock
pass
else:
# No entry or expired and not currently computing -> start computing
entry = _Entry()
entry.event.clear()
cache[key] = entry
# mark this thread as producer by leaving event unset
producer = True
# release lock and compute
# fallthrough
producer_flag = True
# we'll compute below outside lock
compute_entry = entry
break_marker = None
# use a sentinel to indicate producer path
# If we got here, someone else is computing
# Outside lock:
# Wait for in-progress computation to finish and return result
entry.event.wait()
return entry.value
# Producer path: (we intentionally structure so compute happens outside lock)
# compute the value
result = fn(*args, **kwargs)
expiry = time.time() + ttl_seconds
with cache_lock:
compute_entry.value = result
compute_entry.expiry = expiry
compute_entry.event.set()
return result
# The above logic is slightly verbose to be explicit; simpler implementation below:
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items()))) if kwargs else (args, ())
while True:
now = time.time()
with cache_lock:
entry = cache.get(key)
if entry:
if entry.expiry > now and entry.event.is_set():
return entry.value
if not entry.event.is_set():
# someone is computing, take reference and wait
waiter = entry
else:
# expired and no active computation -> become producer
entry = _Entry()
cache[key] = entry
producer = True
break
else:
# absent -> become producer
entry = _Entry()
cache[key] = entry
producer = True
break
# wait for in-progress computation
waiter.event.wait()
# loop to re-check expiry (in case entry was replaced)
# producer does computation outside lock
try:
value = fn(*args, **kwargs)
entry.value = value
entry.expiry = time.time() + ttl_seconds
return value
finally:
entry.event.set()
return wrapper
return decoratorHardTechnical
62 practiced
Design a low-latency in-memory cache for fraud detection rules with requirements: read latency <2ms, updates must propagate and take effect globally within 500ms, and emergency rule revokes must be immediate. Explain architecture for distribution, propagation, ordering, atomicity of updates, and how to handle rollbacks/consistency.
Sample Answer
Requirements (clarify):- Read latency <2ms from application popints.- Updates propagate globally within 500ms.- Emergency revokes must take effect immediately.- Strong ordering/atomicity for rule sets; ability to rollback.High-level architecture:- Each region runs an in-memory cache process (local fast store) colocated with apps (e.g., JVM process or sidecar using RocksDB/LMDB in-RAM). Reads serve locally to meet <2ms.- A central Rule Control Plane (RCP) manages authoritative rule versions and accepts updates via API.Distribution & propagation:- RCP writes are versioned and persisted to a durable log (e.g., Kafka or Raft-backed cluster) to provide total order and durability.- RCP publishes rule-change events to a low-latency pub/sub (Kafka with replication or a WAN-aware message bus). Each cache subscribes and applies updates in log order.- To meet 500ms SLA, use eager push plus a periodic snapshot fallback: push delta events with sequence numbers; caches ack receipt. If a cache misses events, it fetches the latest snapshot from RCP.Ordering & atomicity:- Bundle rule changes into transactions with a monotonically increasing version ID (log offset). Caches apply updates only when they have the next version to ensure global order.- Apply transactional swaps: load new rules into a shadow store, then atomically swap pointer to active rules (lock-free read using immutable structure or atomic pointer swap) to ensure readers never see partial state.Emergency revokes:- Emergency revoke flows as high-priority messages with bypass flag. RCP writes revoke to durable log and also pushes via high-priority channel; caches handle high-priority messages immediately by interrupting normal apply and performing the atomic swap for the revoke rule set. Apps can also poll a tiny local bitset for immediate short-circuit decisions.Consistency, acking, and rollback:- Caches ack receipt; RCP tracks per-region health. If a region fails to ack within threshold, RCP can push snapshot or cut traffic.- Rollbacks: issue a new transaction that reverts to a previous version (version IDs are immutable). For partial failures during apply, caches detect gaps (sequence mismatch) and fetch snapshot.- For stronger guarantees, use synchronous cross-region quorum via Raft for critical updates—accepting higher tail latency versus typical 500ms goal; reserve that for emergency path only.Trade-offs:- Using asynchronous pub/sub gives low read latency and high propagation speed but offers eventual consistency; the versioning + atomic swap gives strong practical ordering.- Synchronous consensus for all updates would simplify consistency but increase update latency. Hybrid: normal updates via log+push; emergency via consensus or prioritized channel.Monitoring & safety:- Health dashboards: lag per region, unacked messages, apply errors.- Canary rollout: tag versions and route small traffic to verify before global swap.- Testing: fault-injection for lost messages, snapshot fetch correctness, and emergency revoke drills.
HardTechnical
58 practiced
Implement a consistent hashing ring in Python that maps arbitrary keys to a set of nodes and supports add_node(node) and remove_node(node) with minimal remapping. Use virtual nodes to improve balance. Provide code outline, explain complexity, and show sample usage mapping keys to hosts.
Sample Answer
To implement consistent hashing with virtual nodes, we place many replicas ("vnodes") of each physical node on a hash ring (0..2^32-1). Keys hash to points on the same ring and are assigned to the next vnode clockwise. Adding/removing a node affects only keys between adjacent vnode positions — minimal remapping.Approach:1. Maintain a sorted dict/list of vnode hash -> physical node.2. Use bisect to find the vnode for a key hash.3. When adding a node, create V virtual nodes with distinct hashes (node name + index).4. Removal deletes those vnode entries.Code outline:Key points:- Uses MD5 for stable 128-bit hashes; cast to int for ordering.- replicas (virtual nodes) improve distribution.Time & space complexity:- add_node/remove_node: O(R * log N) where R = replicas, N = total vnodes on ring.- get_node: O(log N).- Space: O(N) for ring and map.Edge cases:- Empty ring -> get_node returns None.- Duplicate vnode hash unlikely with MD5 but checked.- Choose replicas based on node count and skew tolerance.Sample usage:Alternatives / improvements:- Use consistent hashing with weighted nodes (more vnodes per weight).- Use xxhash or murmur for speed.- Persist ring state or use concurrent structures for multithreaded access.
python
import bisect
import hashlib
def _hash(value):
return int(hashlib.md5(value.encode()).hexdigest(), 16)
class ConsistentHashRing:
def __init__(self, replicas=100):
self.replicas = replicas
self.ring = [] # sorted list of hashes
self.nodes = {} # hash -> node
def add_node(self, node):
for i in range(self.replicas):
h = _hash(f"{node}:{i}")
if h in self.nodes: continue
bisect.insort(self.ring, h)
self.nodes[h] = node
def remove_node(self, node):
to_remove = []
for i in range(self.replicas):
h = _hash(f"{node}:{i}")
if h in self.nodes and self.nodes[h] == node:
to_remove.append(h)
for h in to_remove:
idx = bisect.bisect_left(self.ring, h)
if idx < len(self.ring) and self.ring[idx] == h:
self.ring.pop(idx)
del self.nodes[h]
def get_node(self, key):
if not self.ring:
return None
h = _hash(str(key))
idx = bisect.bisect_right(self.ring, h)
if idx == len(self.ring):
idx = 0
return self.nodes[self.ring[idx]]python
ring = ConsistentHashRing(replicas=200)
ring.add_node("hostA")
ring.add_node("hostB")
ring.add_node("hostC")
for key in ["user:1","user:42","session:xyz"]:
print(key, "->", ring.get_node(key))MediumTechnical
56 practiced
Explain Redis persistence modes (RDB snapshots vs AOF append-only file). For a cache that must occasionally persist important session state, describe which persistence modes or combinations you would enable and why. Discuss performance, durability, restart recovery time, and potential impact on latency.
Sample Answer
Start by summarizing the two modes and their trade-offs.- RDB (snapshotting): Redis forks and writes an on-disk snapshot (dump.rdb) at configured intervals (save rules) or via BGSAVE. Pros: compact file, fast restarts (loading is quicker), low steady-state I/O, minimal per-command latency. Cons: periodic snapshots can lose all changes since the last snapshot (durability gap) and BGSAVE incurs copy‑on‑write (COW) memory/CPU overhead while forking.- AOF (append-only file): Redis appends every write command to an appendonly.aof. Pros: much better durability (can replay nearly all writes), flexible rewrite mechanism compacts file. Cons: larger disk usage, potentially slower restarts (must replay commands), and depending on fsync policy can add per‑write latency or risk data loss.Recommended configuration for a cache that must occasionally persist important session state- Use both RDB and AOF (both enabled). Rationale: AOF gives near-real-time durability for session changes; RDB provides periodic compact checkpoints that make full restarts faster and gives a reliable snapshot if AOF rewrite fails.Suggested settings- appendonly yes- appendfsync everysec — balances durability and latency: risks ≤1s of data loss but avoids per‑command fsync cost that would increase latency significantly. Use fsync=always only if you require zero data loss and accept much higher latency and lower throughput.- Keep sensible RDB save rules (e.g., save 900 1, save 300 10) to have periodic snapshots for faster recovery and as an alternative restore point.- Enable AOF rewrite (auto-aof-rewrite-percentage/defaults) to prevent indefinite growth; ensure the server has enough I/O and spare memory for fork operations.Performance, durability, restart recovery time, latency impact- Durability: AOF (everysec) ≈ durable to within ~1s; RDB only durable to last snapshot interval. AOF (always) gives best durability.- Restart recovery time: RDB loads faster because it's a compact snapshot. AOF needs to replay commands — longer for large AOFs. Having both lets you rely on RDB for faster restarts and AOF for near-real-time durability; Redis will prefer AOF if both are present, but you can restore from RDB if needed.- Runtime latency: appendfsync everysec imposes minimal latency (OS buffers, periodic fsync). appendfsync always adds significant write latency per command. BGSAVE/AOF rewrite (fork) causes temporary extra CPU and memory pressure; copy-on-write can increase memory usage and transient latency spikes under heavy write load.- Throughput: AOF increases disk throughput; tune OS disk settings (O_DIRECT, mount options) and use fast SSDs.Operational notes and safety- Monitor disk I/O and memory to avoid OOM during forks.- Test crash scenarios and AOF rewrite behavior; enable aof-load-truncated yes if you want automatic truncation of a corrupted tail.- For extremely critical sessions where even 1s loss is unacceptable, consider appendfsync always for those specific writes by using WAIT or persisting critical session to a transactional store (RDB/DB) instead of relying solely on Redis.Conclusion- For a cache with occasional important session persistence: enable AOF with appendfsync everysec and keep RDB snapshots as a complementary fast-restart backup. This gives a practical balance of durability (~1s), acceptable latency/throughput, and reasonable restart time. If your SLA requires stricter durability, evaluate appendfsync always or persisting critical sessions to a stronger durable store.
HardTechnical
58 practiced
You have 100 million keys with average JSON value of 1KB in Redis and memory pressure is causing frequent evictions. Describe concrete strategies to reduce memory footprint: changing serialization format, using hashes to pack fields, value compression, policy tuning, offloading cold items to a disk-based store (e.g., RocksDB), and choosing appropriate memory allocators.
Sample Answer
Situation: You’re storing ~100M keys with ~1KB JSON values in Redis and evictions are frequent due to memory pressure. Below are concrete strategies, trade-offs, and implementation notes to reduce memory.1) Change serialization format- Move from text JSON to compact binary (MessagePack, Smile, CBOR) or protocol buffers.- Typical savings: 30–70% depending on field names and sparsity.- Implementation: deploy a versioned serializer so new clients can read old/new formats during rollout.2) Use Redis Hashes to pack fields- Store many small objects as fields in a single Redis hash (hash-max-ziplist-entries/ value limits).- When fields are small, Redis ziplists pack efficiently and reduce per-key overhead.- Strategy: shard keys into buckets (e.g., user:{bucket_id}) to keep hash sizes manageable and avoid hot keys.3) Value compression- Apply LZ4/snappy for low-latency, or ZSTD for higher compression if CPU allows.- Option: compress only cold or large values; keep hot items uncompressed.- Note: benchmark compress/decompress latency vs eviction cost.4) Eviction & policy tuning- Use volatile-lru/volatile-ttl or LFU (allkeys-lfu) to retain hot items.- Configure maxmemory-samples and LFU decay to tune sensitivity.- Set realistic TTLs for ephemeral data and proactively expire stale keys.5) Offload cold data to disk-store (RocksDB)- Use Redis-on-Flash, or a hybrid architecture: keep hot subset in Redis, cold in RocksDB.- Maintain a caching layer that promotes items on access; async write-through to RocksDB.- Consider Redis modules (e.g., Redis-ML pattern) or build a service that transparently migrates keys.6) Memory allocator and Redis build options- Use jemalloc (default) tuned with MALLOC_CONF to reduce fragmentation; monitor fragmentation_ratio.- For large workloads, consider tcmalloc or mimalloc if benchmarks show lower RSS/fragmentation.- Run Redis with memory overcommit disabled and tune vm.overcommit_memory/sysctl for stability.Operational practices- Add monitoring: keyspace size, memory per DB, eviction metrics, LFU counters, RSS vs used_memory.- Rollout staged: A/B test serialization/compression, measure latency, CPU, and eviction reduction.- Cost/CPU trade-off: compression and custom allocators save RAM at CPU or complexity cost—prioritize based on SLOs.Result: Combined approach (binary serialization + packing into hashes + LFU + offloading cold data) typically reduces memory by 3–10x depending on data shape, eliminating most evictions while keeping latency within acceptable bounds.
Unlock Full Question Bank
Get access to hundreds of Caching Strategies and In Memory Storage interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.