Overview
Designing an in-memory key-value database (a mini Redis) tests data structures, command handling, and transactions. Typical requirements: SET, GET, DELETE, COUNT of keys with a given value, TTL expiry, and nested transactions with BEGIN, ROLLBACK, and COMMIT. Some variants add tables with columns, indexes, and simple queries.
Key design choices: a main hash map for data, a secondary index (value to count or set of keys) for fast COUNT queries, a stack of change logs or overlay maps for nested transactions, a min-heap or lazy checks for TTL, and a command parser mapping commands to handlers (Command pattern). Thread safety and persistence (append-only log, snapshots) are follow-ups.
The ledger is the committed data. When you BEGIN, you write changes on a draft sheet on top. ROLLBACK tears off the draft; COMMIT copies it into the ledger. Nested drafts stack on each other.
When to use it
- Interview prompt: 'Design an in-memory key-value store with transactions'.
- Understanding Redis-like systems.
- Machine-coding rounds focused on correctness.
Where it shows up in interviews
Recognize it when: SET/GET/DELETE with BEGIN/ROLLBACK/COMMIT.
- Design an in-memory database
- Simple Database challenge (Thumbtack)
Recognize it when: count or find keys by value quickly.
- Design a KV store with COUNT
- Design a table store with indexed queries
Where it is used in real software
In-memory data store with MULTI/EXEC transactions, TTLs, and persistence via RDB and AOF.
Relational engines that can run entirely in memory for tests.
PostgreSQL keeps row versions so transactions see snapshots, a larger-scale version of overlays.
Key terms
- Primary store
- key -> value hash map.
- Secondary index
- value -> count (or key set) for queries by value.
- Undo log
- Previous values recorded per transaction to roll back.
- Nested transactions
- Stack of transaction scopes; rollback affects only the innermost.
- Lazy expiration
- Expired keys removed when accessed, plus periodic sweeps.
How it works, step by step
- 1Store data
Map<String, String> for key-value.
- 2Maintain a value-count index
Update on every set and delete.
- 3Transactions via undo logs
On first change of a key in a transaction, record its previous value.
- 4ROLLBACK
Restore previous values from the top log and pop it.
- 5COMMIT
Clear all logs (changes are already applied).
STEP 1SET a 10 outside a transaction. Index: value 10 appears once.
Command session
Starting empty
| Command | Output | Notes |
|---|---|---|
| SET x 10 | - | count(10)=1 |
| BEGIN | - | Tx depth 1 |
| SET x 20 | - | log: x=10 |
| GET x | 20 | - |
| COUNT 10 | 0 | Index updated |
| ROLLBACK | - | x restored to 10 |
| GET x | 10 | - |
| ROLLBACK | NO TRANSACTION | Depth 0 |
NOWCommand: SET x 10 | Output: - | Notes: count(10)=1
Undo logs make GET and COUNT O(1) inside transactions, and rollback costs only the number of keys changed.
Implementation
class InMemoryDb { private data = new Map<string, string>(); private valueCounts = new Map<string, number>(); private txLogs: Map<string, string | undefined>[] = []; // stack of undo logs set(key: string, value: string) { this.record(key); this.write(key, value); } delete(key: string) { if (this.data.has(key)) { this.record(key); this.write(key, undefined); } } get(key: string) { return this.data.get(key) ?? "NULL"; } count(value: string) { return this.valueCounts.get(value) ?? 0; } begin() { this.txLogs.push(new Map()); } rollback() { const log = this.txLogs.pop(); if (!log) return "NO TRANSACTION"; for (const [key, prev] of log) this.write(key, prev); return "OK"; } commit() { if (!this.txLogs.length) return "NO TRANSACTION"; this.txLogs = []; // changes already applied return "OK"; } private record(key: string) { const log = this.txLogs.at(-1); if (log && !log.has(key)) log.set(key, this.data.get(key)); // first change in this tx only } private write(key: string, value: string | undefined) { const old = this.data.get(key); if (old !== undefined) this.valueCounts.set(old, this.valueCounts.get(old)! - 1); if (value === undefined) this.data.delete(key); else { this.data.set(key, value); this.valueCounts.set(value, (this.valueCounts.get(value) ?? 0) + 1); } }} // Command parser (Command pattern style dispatch)function execute(db: InMemoryDb, line: string): string { const [cmd, a, b] = line.trim().split(/\s+/); switch (cmd.toUpperCase()) { case "SET": db.set(a, b); return ""; case "GET": return db.get(a); case "DELETE": db.delete(a); return ""; case "COUNT": return String(db.count(a)); case "BEGIN": db.begin(); return ""; case "ROLLBACK": return db.rollback(); case "COMMIT": return db.commit(); default: return "UNKNOWN COMMAND"; }}Complexity and performance
Hash maps.
Undo log size.
Discard logs.
Trade-offs
Undo logs make reads O(1) but writes apply immediately; overlays keep committed data untouched but reads must check each layer.
synchronized methods are simple; per-key locking or single-threaded command loops (like Redis) scale better.
Variants and related techniques
Store expiry times; lazy delete on access plus a heap-based sweeper.
Append-only command log and periodic snapshots for recovery.
Common mistakes
- Recording the previous value on every change in a transaction.
Fix: Record only the first change per key per transaction.
- Forgetting to update the COUNT index on delete or overwrite.
Fix: Centralize writes in one method.
- Scanning all values for COUNT.
Fix: Maintain a value-count index.
Interview questions
How do you support nested transactions?
Keep a stack of undo logs. Each BEGIN pushes a log; the first change to a key within a transaction records its previous value; ROLLBACK restores from the top log and pops it; COMMIT clears all logs.
How do you make COUNT(value) O(1)?
Maintain a secondary index from value to count, updated on every set, overwrite, and delete, including during rollback.
Practice problems
| Problem | Difficulty | What it trains |
|---|---|---|
| KV store with nested transactions | Medium | Undo logs. |
| Add TTL and snapshot persistence | Hard | Expiry and recovery. |