Engineering Atlas › Low-Level Design

Code an in-memory key-value store with transactions

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: durability (a write-ahead log), isolation between concurrent clients, TTLs and range scans. Mention them under Extensibility and move on.

Entities and relationships

The stack is the only relationship that matters: layer n can see layers 0 to n-1 and the base, and nothing sees upward. Commit collapses the top of the stack one step; rollback pops it.

Class diagram

classDiagram
    class Store {
        -base: Map
        -baseCounts: Map
        -layers: List~Layer~
        +set(key, value)
        +get(key) value
        +delete(key) bool
        +count(value) int
        +begin()
        +rollback() bool
        +commit() bool
        +commitAll() bool
    }
    class Layer {
        +changes: Map
        +deltas: Map
    }
    class Tombstone
    Store "1" o-- "0..*" Layer : stack
    Layer ..> Tombstone : marks deletes

Key flows

sequenceDiagram
    participant C as Client
    participant S as Store
    participant L1 as Layer 1
    participant L2 as Layer 2
    C->>S: set(a, 1)
    S->>S: write base
    C->>S: begin()
    S->>L1: push empty layer
    C->>S: delete(a)
    S->>L1: changes[a] = tombstone, delta[1] -= 1
    C->>S: begin()
    S->>L2: push empty layer
    C->>S: set(a, 2)
    S->>L2: changes[a] = 2, delta[2] += 1
    C->>S: get(a)
    S->>L2: found 2
    C->>S: rollback()
    S->>L2: drop layer
    C->>S: get(a)
    S->>L1: found tombstone, so absent
    C->>S: commit()
    S->>S: apply L1 to base, a removed

Walk the flow in words. A set first looks up the current visible value of the key, because the delta needs the old value to decrement. It then records the new value in the top layer, subtracts one from the old value's delta and adds one to the new value's delta. A delete is the same with a tombstone as the new value. A get scans layers newest first and returns the first entry it meets, treating a tombstone as "absent" and stopping there. A count sums the base count and every layer's delta for that value.

Design patterns used and why

Concurrency and edge cases

In the wild

Down the stack

Layer 1The contract

Isolation inside one client, atomicity at each level. After rollback(), every get and count must return exactly what it returned before the matching begin(). After commit() of the innermost level, the parent sees every change as if it had made it itself, and a later rollback of the parent erases them. After the outermost commit(), the base holds the result. SQLite states the same semantics for savepoints: ROLLBACK TO reverts to the state just after the SAVEPOINT and does not cancel the transaction, and RELEASE of an inner savepoint merges it into its parent and writes nothing to the database file (SQLite savepoints). Our rollback is "rollback to and remove", and our commit is RELEASE.

Layer 2The mechanism

Every operation is defined over a visible-value lookup. visible(key) scans layers from the top and returns the first entry found, then falls back to the base. All writes call visible once to learn the old value, then write one entry and two delta adjustments to the top layer. Costs, with d the depth, k the number of keys in the layer and m the number of distinct values:

OperationCost
get, set, delete, countO(d)
begin, rollbackO(1)
commit (inner)O(k + distinct values in the layer)
commit (outermost)the same, applied to the base
commit_allsum over layers; fold from the top down

A real engine has the same split. SQLite keeps a stack of savepoints in a transaction, and releasing one only removes markers from that stack (SQLite savepoints); the page changes themselves reach disk through a rollback journal (or a write-ahead log in WAL mode) when the outermost transaction commits (SQLite atomic commit). PostgreSQL assigns a transaction ID to a subtransaction when it needs one (GitLab blog), and ROLLBACK TO SAVEPOINT undoes work since the savepoint while the outer transaction continues (PostgreSQL SAVEPOINT).

Layer 3The machine

Each layer lookup is a hash and a probe into an open-addressed table. In CPython a dict lookup on a short string key is a few tens of nanoseconds when the table is in cache, and a few hundred if each probe misses to DRAM (rough figures; measure on your hardware). A depth-10 stack therefore turns one logical read into ten probes, which is why deep nesting is unusual and why begin should stay cheap rather than speculatively copy. Each layer is also an allocation of two small hash tables, so a loop that opens and closes a transaction per item pays allocator and garbage-collector cost; reuse or flatten if profiling shows it. Go's maps are not safe for concurrent use and its runtime aborts a program that detects concurrent map writes, which is why the mutex matters there.

Why it's built this way

  1. Data lives in RAM and is addressed by key, so a hash table gives O(1) reads. That is the base.
  2. Copying the table to allow rollback costs O(n) per begin, which is far too slow for a store with millions of keys. So record only differences.
  3. Differences must distinguish "deleted" from "unchanged", so deletes become tombstones.
  4. A scan to answer count would be O(n), so maintain counts incrementally. The counts are state too, so they must be layered in the same way, as deltas.
  5. Nesting makes layers a stack, and the stack's cost model (reads O(depth), merge on commit) follows directly.

Extensibility

Leadership lens

Check yourself

  1. Why does the layer store a tombstone for a delete instead of removing the key from its own map?

    Show answer

    Absence from a layer means "ask the parent". Removing the entry would make the parent's value visible again, so the delete would be undone.

  2. A transaction sets a=1 where a was absent, then deletes a, then rolls back. What is count(1) and why?

    Show answer

    Unchanged from before begin. The set added +1 and the delete added -1 to that layer's delta, and rollback drops the layer anyway.

  3. What goes wrong if commit copies the child's deltas over the parent's instead of adding them?

    Show answer

    The parent's own count changes for the same value are lost, so count is wrong after the commit and stays wrong after the base absorbs it.

  4. When would you choose an undo log over overlays, and what do you give up?

    Show answer

    Choose it when reads and counts dominate and transactions are short, since reads are O(1). You give up cheap rollback and clean isolation, because uncommitted data sits in the base.

Sources and further study

Primary

Systems docs

Books

Other curricula

Free resources

Open in the interactive app: practice, quiz, mock interview →