PracHub
QuestionsLearningGuidesInterview Prep
|Home/System Design/Databricks

Design a single-node persistent in-memory cache

Last updated: Jun 24, 2026

Quick Overview

This question evaluates a candidate's skills in single-node cache design, including in-memory key/value storage, eviction policy management (e.g., LRU), durability considerations, concurrency control, and techniques for reducing contention such as sharding or striping.

  • hard
  • Databricks
  • System Design
  • Software Engineer

Design a single-node persistent in-memory cache

Company: Databricks

Role: Software Engineer

Category: System Design

Difficulty: hard

Interview Round: Technical Screen

## Scenario Design a **single-machine** cache used by a web service to handle read/write requests. The cache should: - Store key/value pairs in memory (fast reads) - Support typical operations: `Get(key)`, `Put(key, value)`, `Delete(key)` - Enforce an eviction policy such as **LRU** when memory capacity is reached - Optionally provide **durability** so that after a process crash/restart, the cache can recover its contents The interviewer expects: - A concrete design with data structures - Pseudocode-level APIs and key internal logic - Discussion of concurrency (locks), minimizing critical sections, avoiding deadlocks - How to scale on a single machine (e.g., sharding/striping to reduce lock contention)

Quick Answer: This question evaluates a candidate's skills in single-node cache design, including in-memory key/value storage, eviction policy management (e.g., LRU), durability considerations, concurrency control, and techniques for reducing contention such as sharding or striping.

Solution

This is a **low-level / single-machine** design question, not a distributed-systems question. The interviewer wants concrete data structures, real pseudocode, fine-grained reasoning about locks and critical sections, deadlock analysis, and a credible durability story (WAL + recovery). I structure the answer accordingly and keep "scale" scoped to one box (threads, cores, memory bandwidth) rather than multiple machines. ### 1) Requirements & design decisions **Functional** - `Get(key) -> value | miss`, `Put(key, value)`, `Delete(key) -> bool`. - Eviction policy: **LRU** when capacity is reached. - Optional **durability**: survive a process crash / restart and recover contents. **Non-functional** - Single process, **many worker threads** (web-service request handlers) hitting the cache concurrently. - Low latency: `Get` / `Put` should be roughly $O(1)$ and avoid blocking on disk I/O. - High concurrent throughput → minimize lock contention, no deadlocks. - Bounded memory: enforce a hard cap. I cap by **byte count** (more realistic than entry count for variable-sized values). **Decisions I'd lock down with the interviewer (with my defaults)** - *Capacity unit?* → **bytes** (entry-count cap is a trivial special case). - *Durability strength?* → make it a **knob**: **sync** (`fsync` before ack, survives OS/power crash) vs **async** (background flush, may lose the last few ms). Default async; tighten to sync on request. - *Consistency per key?* → linearizable per key: a `Get` ordered after a successful `Put` / `Delete` observes that write. No cross-key transactions in scope. - *Value semantics?* → values are immutable byte blobs; I return a **ref-counted** copy, not a raw pointer into the cache, so a concurrent eviction can't free memory under a reader. - *Exact vs approximate LRU?* → exact **per-shard** LRU; global exact LRU is deliberately given up when I shard (justified in §5b). ### 2) Back-of-envelope sizing (single box) Order-of-magnitude only, to justify the design — not real benchmarks: - Target a cache of, say, $32\,\text{GB}$ RAM, average value $1\,\text{KB}$ → on the order of $3\times10^7$ entries. - Per-entry overhead: key + `Node` (two list pointers + size field) + hash-table slot ≈ tens of bytes; meaningful at $10^7$ entries, so I track bytes **including overhead**. - Throughput: a single global lock serializes everything; a critical section of ~$100\,\text{ns}$ caps you near $10^7$ ops/s, and a multi-core box then spends most of its time spinning/parking on that one lock. Sharding into $S$ stripes lets independent keys proceed in parallel, so throughput scales with cores until you hit memory bandwidth — the main motivation for striping. - **Never do disk I/O inside the lock.** An `fsync` is on the order of $10^{-3}\,\text{s}$; holding a shard lock across it would collapse throughput by ~$10^4\times$. The WAL `fsync` always happens outside the critical section (the cheap buffer-append may happen inside it — see §6). ### 3) Core in-memory data structure (the LRU) Classic $O(1)$ LRU = **hash map + intrusive doubly linked list**: - `HashMap<Key, Node*>` for $O(1)$ lookup. - Doubly linked list orders entries by recency: **head = most-recently-used**, **tail = least-recently-used**. - `Node` holds `key, value, prev, next, size`. The `key` is stored on the node too, so eviction can erase the map entry without a reverse lookup. ```text struct Node { Key key Value value // immutable, ref-counted buffer Node* prev Node* next size_t size // bytes charged to capacity (key+value+overhead) } ``` Why doubly linked: moving a node to the head on access and unlinking the tail on eviction are both $O(1)$ only if the node knows its neighbors. A singly linked list makes move-to-head $O(n)$ (you'd have to find the predecessor). ### 4) Single-thread logic (correctness first, locks later) Pseudocode for one shard (each shard owns its own map + list + byte counters). `cur_bytes` / `max_bytes` bound the shard. Locking is added in §5; here I show only the in-memory mutation each op performs while it holds its shard lock. ```text Get(k): node = map.find(k) if node == null: return MISS move_to_head(node) return node.value // immutable, ref-counted buffer (kept alive past lock release; see §5c) Put(k, v): node = map.find(k) if node != null: # update existing cur_bytes += size(v) - node.size node.value = v; node.size = size(v) move_to_head(node) else: # insert new node = new Node(k, v) map[k] = node push_front(node) # new entry is MRU → at the head cur_bytes += node.size evict_until_under_cap() Delete(k): node = map.find(k) if node == null: return false unlink(node); map.erase(k); cur_bytes -= node.size return true evict_until_under_cap(): while cur_bytes > max_bytes and list not empty: victim = tail unlink(victim); map.erase(victim.key); cur_bytes -= victim.size # if durable: stage an EVICT/DEL record for victim.key (see §6) ``` **Capacity by bytes:** charge `size = bytes(key) + bytes(value) + fixed_overhead`. A `Put` that updates an existing key must adjust `cur_bytes` by the *delta*, and may itself trigger eviction of other keys to make room. The just-written key is never the victim: `push_front` / `move_to_head` makes it the **head (MRU)**, and `evict_until_under_cap` always evicts from the **tail (LRU)** — so the new key is structurally safe regardless of when the eviction loop runs. (The genuine edge case — a single value larger than the whole shard cap — is handled in §7, not here.) ### 5) Concurrency design #### 5a. Naive baseline: one global mutex One `mutex` guards map + list + counters. Correct and trivial, but every `Get` is a **write** (it reorders the list), so even read-heavy traffic fully serializes. Unacceptable on a multi-core box. State it, then improve. #### 5b. Recommended: sharded / striped cache Partition the key space into $S$ independent shards (e.g. $S = 256$, a power of two): ```text shard = shards[ hash(key) & (S - 1) ] // S power of two → mask, no modulo ``` - Each shard has its **own lock, map, list, and byte cap** (`max_bytes / S`). - Independent keys land in different shards → their operations run fully in parallel. - Contention now scales with collisions in the *same* shard, not all traffic. Pick $S \gg$ core count to keep collision probability low. **Tradeoff — LRU becomes per-shard, not global.** A globally-cold key in a hot shard can be evicted while a warmer key in a quiet shard survives. With a decent hash and $S \gg$ cores, the hit-rate loss is small in practice; I'd call it **"sharded LRU"** and flag it as the deliberate cost of removing the global lock. (True global LRU needs a single shared recency structure, which reintroduces the global hot spot — not worth it here.) #### 5c. Minimizing the critical section - **`Get` mutates recency**, so the obvious implementation takes the shard lock exclusively even on reads. A `RWLock` doesn't help much, because move-to-head is a write; the read fraction that *also* reorders still needs exclusive access. Two ways to make `Get` lighter if reads dominate: 1. **Sample/approximate LRU:** only move-to-head probabilistically (e.g. 1 in $N$ accesses), or use a **CLOCK / second-chance** bit instead of strict list moves. Reads become a lock-light "set a referenced bit" op; eviction sweeps the clock hand. This trades exact LRU for cheaper reads. 2. Keep strict LRU but make the locked region *only* the pointer surgery: bump the value's ref-count under the lock, release the lock, then copy/read the buffer **after** the lock is dropped (the ref-count taken under the lock keeps it alive even if a concurrent eviction unlinks the node). - **Do all expensive work outside the lock:** allocation of the new `Node` / value buffer, hashing, and especially WAL `fsync` happen before/after the locked region, never inside it. The locked region is just: map lookup/insert, list splice, counter update, and (when durable) a cheap **buffered** WAL append (§6). #### 5d. Deadlock avoidance Single-key ops take exactly one shard lock → **no deadlock possible** (you can't hold-and-wait across two locks). The only deadlock risk is a *multi-key* operation (e.g. an atomic multi-`Put`, or a background snapshot that wants several shards). Rules: - **Global lock ordering:** always acquire shard locks in ascending shard-id order. A cycle in the wait-for graph is impossible if every thread acquires in the same total order. - Or **`try_lock` + backoff**: lock shard A, `try_lock` shard B; on failure release A and retry, breaking hold-and-wait. - Snapshots sidestep the problem by locking **one shard at a time** (§6). This is exactly the "eyeball the deadlock" follow-up the interviewer probes: the invariant to recite is *single lock per op, or a strict global acquisition order for multi-lock ops.* ### 6) Durability: Write-Ahead Log + snapshots **Goal:** after crash/restart, rebuild cache contents. Approach: append-only **WAL** for incremental writes + periodic **snapshot** to bound WAL size. #### WAL record format Append-only, self-describing, corruption-detecting: ```text [ len:4 ][ type:1 ][ lsn:8 ][ key_len:4 ][ key ][ val_len:4 ][ value ][ crc32:4 ] type ∈ { PUT, DEL } // EVICT is logged as DEL to keep replay deterministic ``` - **Length prefix + trailing CRC** detect a torn final write (crash mid-append): on recovery, a record whose CRC fails or whose length runs past EOF is discarded — we stop replay there. - **LSN** (monotonic log sequence number) correlates WAL position with snapshots. #### Write path (two durability modes — a knob) The subtle correctness point: **the WAL must reflect the order in which operations took effect in memory.** If two threads `Put` the same key, the WAL order must match the in-memory winner, or recovery diverges from the live state. The guarantee is non-negotiable: **assign the LSN and append the record under the same shard lock that applies the mutation**, so for any key, *log order == apply order* by construction. The append is just a buffered write (cheap); the `fsync` still happens after the lock is released. ```text Put(k, v): # durable, canonical path shard = shard_for(k) rec = encode(PUT, k, v) # encode/alloc OUTSIDE the lock (no I/O here) lock(shard) lsn = wal.append(rec) # assign LSN + buffered append, UNDER the lock, # so log order == in-memory apply order for this key. # Buffered only — NO fsync inside the lock. apply Put(k, v) in memory # §4 logic, same critical section unlock(shard) if mode == SYNC: wal.fsync_through(lsn) # fsync OUTSIDE the lock; ack only after durable return ok ``` - **SYNC durability:** acknowledge only after `fsync` covers this record's LSN. Survives OS/power loss; higher latency. Use **group commit**: a background flusher `fsync`s once and wakes all writers whose LSN ≤ the flushed point — amortizes one `fsync` over many writes. - **ASYNC durability:** a background thread flushes the WAL buffer periodically; `Put` acks immediately after `unlock`. Lower latency, may lose the last few ms on crash. Default. Why append *inside* the shard lock rather than before it: appending before `lock(shard)` would decouple LSN-assignment order from in-memory-apply order — two concurrent `Put`s to the same key could append in one order but apply in the other, so the recovered last-writer would differ from the live last-writer. Doing both under the one lock removes that race. The cost is that WAL appends serialize *per shard* (not globally); since the WAL is the union of all shards' records ordered by LSN, and recovery only needs *per-key* last-writer-wins (independent keys are independent), per-shard append serialization is exactly sufficient. A single global total order of appends across keys would require a global log-append lock — explicitly out of scope for a cache recovering independent keys. #### Recovery (on startup) ```text recover(): load latest valid snapshot S # gives state up to snapshot_lsn for rec in WAL where rec.lsn > snapshot_lsn: if crc_ok(rec): apply rec (PUT/DEL) into memory # in ascending LSN order else: break # torn/partial tail record → stop # capacity may now be exceeded → run eviction to re-enforce caps re-run evict_until_under_cap() per shard ``` Replaying `PUT` / `DEL` in LSN order reconstructs the map; because evictions were logged as `DEL`, replay is deterministic and won't resurrect evicted keys. #### Snapshots / compaction (bound WAL growth) The WAL grows unbounded; periodically snapshot and truncate: - A background snapshot writes current contents to a new file and records `snapshot_lsn` (the highest LSN included). - After the snapshot is durably written, **truncate the WAL** before `snapshot_lsn`. - **Snapshotting without a long global pause:** iterate **shard by shard** — lock one shard, serialize its map, unlock, move to the next. This never holds more than one lock (deadlock-free) and never freezes the whole cache. The snapshot is then *fuzzy* (different shards captured at slightly different times); correctness holds because any write during the snapshot is also in the WAL after `snapshot_lsn`, so recovery (snapshot + WAL-tail replay) is consistent. A copy-on-write page snapshot is the more advanced alternative but rarely needed in an interview. #### Eviction-vs-durability semantics (be explicit) Two coherent choices: 1. **WAL reconstructs exact in-memory state** → log evictions as `DEL` (chosen above). Replay reproduces the live cache exactly. 2. **WAL is best-effort warm-start** → log only client `PUT` / `DEL`, *don't* log evictions; on recovery the cache may temporarily exceed capacity and re-evicts itself. Simpler, but recovered set ⊇ live set. I'd pick (1) for determinism and mention (2) as the cheaper alternative. ### 7) Bottlenecks & tradeoffs | Concern | Choice | Tradeoff | |---|---|---| | Lock contention | Stripe into $S$ shards | Loses global LRU → "sharded LRU"; hit-rate slightly lower | | Read cost (reads mutate recency) | Exact list-move under lock, **or** CLOCK/sampled LRU | Cheaper reads vs strict recency accuracy | | Durability latency | SYNC + group commit vs ASYNC | Data-loss window vs per-op latency | | WAL growth | Snapshot + truncate | Snapshot CPU/IO + fuzzy-snapshot reasoning | | Memory fragmentation at $10^7$ entries | Slab / arena allocator, pool `Node`s | Implementation complexity | | Hot key in one shard | More shards / re-hash | Diminishing returns; a single hot key can't be parallelized | | Use-after-free on eviction during read | Return ref-counted value buffers | Extra atomic ref-count ops | **Key edge cases:** - Concurrent `Put` + `Get` on the same key → resolved by per-shard lock + ref-counted values. - Crash mid-WAL-record → CRC/length framing stops replay at the torn tail. - A single value larger than a shard's `max_bytes` → **reject it / mark uncacheable**, otherwise `evict_until_under_cap` loops forever trying to get under cap. - `Put` updating an existing key → adjust `cur_bytes` by the **delta**, not the full size. ### 8) Answers to the follow-up probes - **"Eyeball the deadlock" in a multi-key op.** Deadlock requires a cycle in the wait-for graph (hold-and-wait + circular wait). Enforce a **total order on shard ids** and always acquire locks in ascending order; a cycle is then impossible. Operationally, compute the set of shards the op touches, sort, and lock in that order — or use `try_lock` + release-and-retry to break hold-and-wait. Audit every code path that takes >1 lock against this rule. - **"Make the `Get` critical section even smaller."** Inside the lock keep only: the map lookup, the list splice (move-to-head), and (if exact LRU) nothing else. Move outside the lock: hashing the key (before locking), the **value copy/read** (take a ref-count under the lock, then read after release), and any allocation. If reads dominate, switch the in-lock work from a list splice to setting a single CLOCK reference bit — cheaper still. - **Snapshot without freezing the cache.** Walk shard-by-shard: lock one shard, serialize it, unlock, advance. No global pause, never >1 lock held. The result is a **fuzzy snapshot** (shards captured at slightly different instants), which is still correct because every write concurrent with the snapshot also lands in the WAL at an LSN `> snapshot_lsn`; recovery applies snapshot then replays the WAL tail, reconciling the skew. - **Log evictions or not?** Logging evictions as `DEL` makes recovery reproduce the live cache **exactly** (deterministic). Not logging them makes the WAL a best-effort warm-start: recovery may overshoot capacity and re-evict, so recovered set ⊇ live set. For this cache I log evictions (determinism); the cheaper option is reasonable when only warm-start matters. ### 9) Summary In-memory cache = **hash map + intrusive doubly linked list** for $O(1)$ `Get` / `Put` / `Delete` / LRU. Concurrency = **stripe into $S$ shards**, one lock each, so independent keys run in parallel; keep the critical section to pure pointer surgery plus a cheap buffered WAL append, and never `fsync` under a lock; deadlock-free because each op holds one lock (multi-lock ops use a strict global lock order). Durability = **WAL** (length-prefix + CRC framing, SYNC-with-group-commit or ASYNC modes) plus **per-shard fuzzy snapshots** to bound log size; recovery = load snapshot, then replay the WAL tail in LSN order, re-enforcing capacity. The non-negotiable invariants: assign each record's LSN under the same shard lock that applies the mutation (so log order == apply order per key), and keep disk I/O strictly outside critical sections. The deliberate cost is sharded (not global) LRU.

Related Interview Questions

  • Design Chat Deletion Semantics Under Concurrent Sends - Databricks (medium)
  • Design a Durable Concurrent Event Writer - Databricks (hard)
  • Design a Collaborative Playlist Editor - Databricks (medium)
  • Design a Thread-Safe Buffered Writer with a Background Flush Thread - Databricks (medium)
  • Design a Chat Application with a Partitioned Cache - Databricks (medium)
|Home/System Design/Databricks

Design a single-node persistent in-memory cache

Databricks logo
Databricks
Jan 22, 2026, 12:00 AM
hardSoftware EngineerTechnical ScreenSystem Design
209
0

Scenario

Design a single-machine in-memory cache used by a web service to serve concurrent read and write requests. This is a deliberately low-level system design question (closer to "implement the cache" than "draw boxes for a distributed system"): the interviewer wants concrete data structures, pseudocode-level APIs, and detailed reasoning about locks.

The cache should:

  • Store key/value pairs in memory for fast reads.
  • Support the typical operations Get(key) , Put(key, value) , and Delete(key) .
  • Enforce an eviction policy (e.g. LRU ) when the configured memory capacity is reached.
  • Optionally provide durability , so that after a process crash or restart the cache can recover its contents.

Produce a concrete design: name the data structures, sketch the operations as pseudocode, reason explicitly about concurrency (which locks are held, how to keep critical sections small, how to avoid deadlock), and explain how the design scales across the cores of a single machine.

Constraints & Assumptions

  • Single process, many worker threads. The web service's request handlers all hit the cache concurrently; the design must scale with cores on one box (no second machine).
  • Latency: Get / Put / Delete should be roughly O(1)O(1)O(1) and must never block on disk I/O inside a critical section.
  • Bounded memory: enforce a hard capacity. Assume the cap is by byte count (values are variable-sized) — an entry-count cap is the trivial special case.
  • Per-key consistency: a Get ordered after a successful Put / Delete on the same key observes that write. No cross-key transactions are in scope.
  • Durability is optional / configurable , not always-on. When enabled, "recover contents after restart" is the goal; the strength (lose-nothing vs. lose-last-few-ms) is a knob to discuss, not a fixed requirement.

Clarifying Questions to Ask Guidance

  • What does the capacity cap measure — number of entries, or total bytes (keys + values + overhead)?
  • How strong must durability be: fsync -before-ack (survive OS / power loss) or best-effort async flush (may lose the last few milliseconds)?
  • Is exact LRU required, or is approximate recency (e.g. CLOCK / sampled LRU) acceptable in exchange for cheaper reads?
  • What are the value semantics — immutable blobs, or mutable? (This decides whether a reader can hold a reference while another thread evicts the entry.)
  • Is the read/write mix read-heavy ? (Read-heavy traffic changes whether it's worth optimizing the read path's locking.)
  • Are any multi-key atomic operations in scope (e.g. an atomic multi- Put ), or is every operation single-key?

What a Strong Answer Covers Guidance

A strong answer treats this as an implementation problem and goes deep on the following dimensions:

  • Core data structure: the O(1)O(1)O(1) LRU built from a hash map plus an intrusive doubly linked list, with a clear justification for why the list must be doubly linked and why the node stores its own key.
  • Operation pseudocode: correct Get / Put / Delete / eviction logic, including byte-accurate capacity accounting (a Put that updates an existing key adjusts by the delta ).
  • Concurrency model: progression from a naive global lock to sharded / striped locking; quantitative intuition for why striping helps; and the explicit tradeoff that sharding gives up global LRU for per-shard LRU.
  • Critical-section discipline: keeping the locked region to pure pointer surgery, doing allocation / hashing / disk I/O outside the lock, and the read-path nuance that Get mutates recency.
  • Deadlock reasoning: why single-key ops can't deadlock, and the invariant (single lock per op, or a strict global lock-acquisition order) for any multi-lock operation — the interviewer will probe this hard.
  • Durability: a WAL with self-describing, corruption-detecting records; sync vs. async commit modes; a recovery procedure; and snapshots / compaction to bound log growth. The subtle correctness point — log order must match in-memory apply order per key — earns strong signal.
  • Edge cases & bottlenecks: a single value larger than a shard's cap, use-after-free when a read races an eviction, hot keys concentrated in one shard, and the read-vs-recency tradeoff.

Follow-up Questions Guidance

  • The interviewer asks you to eyeball a potential deadlock in a multi-key operation. What invariant guarantees deadlock freedom, and how would you enforce it across all code paths?
  • "Can the critical section be made even smaller?" — for the Get path that mutates recency, what specifically can you move outside the lock, and what must stay inside?
  • How do you take a snapshot without freezing the entire cache , and why is a per-shard "fuzzy" snapshot still correct given concurrent writes?
  • If you log evictions to the WAL vs. not logging them, how does recovered state differ — and which would you choose for this cache?

Submit Your Answer to Earn 20XP

Sign in to leave a comment

Loading comments...

Browse More Questions

More System Design•More Databricks•More Software Engineer•Databricks Software Engineer•Databricks System Design•Software Engineer System Design

Your design canvas — auto-saved

PracHub

Master your tech interviews with 9,000+ real questions from top companies.

Product

  • Questions
  • Learning Tracks
  • Interview Guides
  • Resources
  • Premium
  • For Universities

Browse

  • By Company
  • By Role
  • By Category
  • Topic Hubs
  • SQL Questions
  • AI Coding Questions
  • Compare Platforms
  • Discord Community

Support

  • support@prachub.com
  • (916) 541-4762

Legal

  • Privacy Policy
  • Terms of Service
  • About Us

© 2026 PracHub. All rights reserved.