Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design)  Module 16 – Case studies: infrastructure components

Low-level design: key-value store with TTL

Design an in-memory key-value store: lazy and sampled expiry, nested transactions as change sets with tombstones, and commits that catch lost updates.

  • Intermediate
  • 35 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free intro, Pro: 10 of 35 minutes free
Free: The prompt, the requirements and the first design. With a pass: the other 12 sections and 3 interview answers, about 25 minutes.

What you will learn

  • Implement get, set and delete with expiry, both lazy and periodic
  • Add nested transactions with begin, commit and rollback
  • Design operations that stay thread-safe and snapshot-friendly
  • Catch lost updates between sessions with versions

Before you start

What you’ll learn · Before you start

What you will learn

  • Implement get, set and delete with expiry, both lazy and periodic
  • Add nested transactions with begin, commit and rollback
  • Design operations that stay thread-safe and snapshot-friendly
  • Catch lost updates between sessions with versions

Before you start

On this page

An in-memory key-value store with a time to live (TTL) keeps values under keys inside one process, forgets each key when its time is up, and lets a client group changes into transactions. In a low-level design (LLD) round it comes down to four classes. A Store maps each key to an Entry: the value, an optional deadline and a version number, behind one lock. Expiry has two halves: an operation that finds a key past its deadline deletes it, and a periodic cycle deletes expired keys that nobody reads. A Session runs one client’s commands, and its nested transactions are a stack of change sets: a read looks in the innermost set first, a delete writes a tombstone, ROLLBACK drops the top set and COMMIT merges it into the one below. Time comes from a Clock the store is given.

The prompt, the requirements and the first design

The prompt: “Design an in-memory key-value store for an app’s login sessions and one-time codes, with SET, GET, DEL, EXPIRE and TTL, keys that expire, and nested transactions with BEGIN, ROLLBACK and COMMIT.” Five questions shape it:

  1. Values and units? Strings, and times to live in milliseconds.
  2. What does a read inside a transaction see? Its own writes first; other clients see them after COMMIT.
  3. COMMIT inside a nested transaction? It merges outwards; only the outermost reaches the store.
  4. Many clients? Yes, each with its own session.
  5. Memory? Expired keys must not pile up unread.

The answers become numbered requirements:

  • K1 SET stores a value and replaces any old value and its expiry.
  • K2 GET returns the value, or nothing for a missing or expired key.
  • K3 DEL removes a key; EXPIRE gives a key a new time to live, and 0 or less deletes it.
  • K4 TTL answers the milliseconds left, -1 for a key without expiry and -2 for a missing key.
  • K5 An expired key is never returned, and its memory comes back even if nobody reads it.
  • T1 to T4 Transactions nest; a read sees the innermost change first; ROLLBACK discards the innermost transaction and COMMIT merges it outwards; the outermost COMMIT applies everything at once, or nothing.
  • N1 Time comes from a clock the store is given; N2 every operation and every commit is atomic.
Class diagram: a Session owns a stack of change sets and commits to a Store, which owns Entries and reads a Clock.Session+stacklist[change set]+readkey -> version+execute(line)str+begin()+rollback()bool+commit()strChangeSet+changeskey -> (value, deadline) or tombstoneStore+datakey -> Entry+volatilekeys with a deadline+lockLock+set(key, value, ttl)+get(key)str | None+expire(key, ttl)bool+expire_cycle()int+apply(changes, read)boolEntry+valuestr+deadlineint | None+versionintClock+«interface»+now_ms()int0..*reads, commits to0..*reads

The classes of the key-value store

Text description of the diagram

The diagram shows five classes in UML notation, each box naming a class, then its attributes and, below a line, its operations. A filled diamond marks the owner of a composition.

  • Session holds stack, a list of change sets, and read, a map from each key the open transaction read to that key's version in the store. Its operations are execute(line), which returns the reply text, begin(), rollback(), which returns a boolean, and commit(), which returns a text. It owns zero or more ChangeSets, and an arrow says it reads from and commits to the Store.
  • ChangeSet holds changes, a map from each key to a value with its deadline, or to a tombstone.
  • Store holds data, a map from each key to an Entry, volatile, the keys that have a deadline, and one lock. Its operations are set(key, value, ttl), get(key), expire(key, ttl), expire_cycle(), which returns how many keys it deleted, and apply(changes, read), which returns whether a commit was applied. It owns zero or more Entries and reads a Clock.
  • Entry has a value, a deadline that may be None, and a version.
  • Clock is an interface with one operation, now_ms(), which returns whole milliseconds.

Expiring keys only when someone reads them looks enough, until the keys that nobody reads again are counted:

10,000 sessions with a 30-minute TTL, expired only on read Python · opening/lazy_only.py
"""Why a store cannot expire keys only when they are read.

A store keeps 10,000 login sessions, each with a time to live of 30 minutes, and removes an expired entry only when a
read finds it. An hour later, 200 of those users come back.
"""

MINUTE = 60_000


class LazyOnlyStore:
    def __init__(self):
        self.entries = {}  # key -> (value, deadline in ms)

    def set(self, key, value, ttl_ms, now):
        self.entries[key] = (value, now + ttl_ms)

    def get(self, key, now):
        entry = self.entries.get(key)
        if entry is not None and entry[1] <= now:
            del self.entries[key]  # expired: removed, but only because someone asked
            return None
        return None if entry is None else entry[0]


store = LazyOnlyStore()
for user in range(10_000):
    store.set(f"session:{user}", "token", ttl_ms=30 * MINUTE, now=0)

now = 60 * MINUTE
expired = sum(1 for _, deadline in store.entries.values() if deadline <= now)
print(f"after an hour:  {len(store.entries):,} entries held, {expired:,} of them expired")
answers = [store.get(f"session:{user}", now) for user in range(0, 10_000, 50)]
print(f"200 reads later: every read answered {set(answers)}; {len(store.entries):,} entries held, all expired")

Output

after an hour:  10,000 entries held, 10,000 of them expired
200 reads later: every read answered {None}; 9,800 entries held, all expired

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy_only.py

An hour later all 10,000 entries had expired, and 200 reads removed only the 200 they touched. The design names these options:

  • Lazy expiry: an operation that finds a key past its deadline deletes it and answers as if it were missing.
  • Sampling: a periodic cycle tests a few random keys that have a deadline and deletes the expired ones.
  • A deadline heap: a min-heap of deadlines pops every key whose time has come, exactly on time.
  • Tombstone: a marker in a change set that says “deleted here”, so an older value below stays hidden.
  • Change sets: each open transaction keeps only what it wrote, and reads fall through to the store.
  • Copy at BEGIN: a transaction starts with its own copy of the whole store.
  • Version check: a commit fails when a key it read has changed since.

Redis documents the same two halves for its EXPIRE command, a passive way on access and an active way that tests a few random keys with an expiry.

Not affiliated

Based on public information about Redis. MySmartCoPilot is not affiliated with Redis, and Redis’s real expiry and transactions may differ from what this lesson describes.

The deep dives that decide whether this design holds:

  1. How do the Store, its Entries and a Session look in code, and what does one session print?
  2. What do lazy expiry, sampling and a heap cost in memory and work, measured?
  3. Why do deletes inside a transaction need tombstones, and what must a rollback restore?
  4. What happens when two sessions change the same key, and how does a commit notice?
  5. How do locks, shards and snapshots fit, and what changes in five other languages?

An answer is judged on the requirements, the split between store, sessions and clock, the expiry policy and its numbers, transactions that keep their promises, and concurrency.

The store and a session in code Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Expiry three ways, measured
  • Nested transactions: change sets, tombstones and what a rollback restores
  • Two sessions, one key: the lost update and the version check
  • Locks, shards and snapshots
  • One session, six languages
  • Practise: COUNT by value in O(1), inside transactions
    • COUNT by value in O(1), inside nested transactions
  • Designs this one rejects
  • What each level is expected to cover
  • A 45-minute self-mock
  • Check yourself
  • Interview questions
    • Why does a key-value store with TTLs need both lazy and active expiry?
    • Inside a transaction a client deletes a key, then reads it and gets the old value back, and after COMMIT the key still exists. Explain the bug and fix it, and say what ROLLBACK must restore when a transaction changed a key's expiry.
    • Your store holds 2 million session keys with 30-minute TTLs, and memory keeps growing although most users never come back. Expiry is lazy only. Design active expiry: what it examines, how often, what it costs, and how you choose between sampling and a heap of deadlines.
    • Two clients each run BEGIN, GET views:42, SET views:42 to that value plus one, and COMMIT, at the same moment, and the counter rises by one instead of two. Explain why, and change the commit so this cannot happen without locking the key for the whole transaction.
  • Key takeaways

1 diagram · 5 runnable examples (Scenario.java, expiry.py, kv.py, run_six.py, scenario.cpp, scenario.go, scenario.mts, scenario.py, scenario.rs, session.py, sessions.py, tx_drill.py) · 1 exercise with sample tests · 3 interview answers · about 26 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Low-level design: parking lot (the model)

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.