The idea
What it is
“Design an in-memory key-value store. It should support TTL.” Two sentences, and the second one is the whole interview. Without it you are writing a wrapper around a hash map and you will be finished in nine minutes.
With it, you are writing a miniature Redis. Somebody hands you a key, a value, and a number of seconds, and from that moment the store owes them a promise: after those seconds, this key is gone. The interesting question is what “gone” means when there is no thread standing over the key with a stopwatch.
The whole system in three sentences
An Entry holds a value and an absolute expiresAt instant. A get reads the map, compares expiresAt to now, and if the instant has passed it deletes the key and reports a miss — that is lazy expiry. A background sweeper samples random keys with TTLs and deletes the dead ones, because a key nobody ever reads again would otherwise sit in memory forever.
That is roughly 200 lines of code and it is genuinely all of it. What separates a good round from a mediocre one is whether you notice the strange middle state — a key that is logically gone but physically present — and whether you can say out loud why every real cache server lives with it.
What is actually being graded
- Is a TTL an instant or a countdown? Storing
expiresAt = now + ttlonce is right. StoringsecondsLeftand decrementing it means something has to do the decrementing, for every key, forever. - Do you know that expiry is not an event? Nothing fires when a key expires. If you describe a
Timerper key, or a callback, the round is effectively over — a million keys would mean a million timers. - Do you have both strategies? Lazy expiry alone leaks any key nobody reads again. A full O(n) sweep alone stalls the server. The answer is lazy plus sampled active expiry, and being able to describe the sampling loop is a standout moment.
- Is the API honest?
ttl(key)must distinguish three states — remaining time, exists but never expires, and no such key. A method that returns-1for two different situations is a bad API, and interviewers notice. - Does it run, and is the clock injectable?
get(key)reading aClockinterface means your TTL tests are three lines and instant.System.currentTimeMillis()sprinkled through the code means every test needs aThread.sleep.
Mechanics
How it works
Step 1 · Clarify — 4 minutes
- Single process, or a cluster? — say single process, and offer the distributed version as a follow-up. In-process means one lock is a real answer; across machines it is not an answer at all.
- Are values just strings? — assume strings, and say you would make
Entry.valuea sealed type if they wanted lists, sets and hashes. It does not change the design, which is worth saying because they are checking whether you think it does. - Is there a memory limit? — yes, and this is the question that gets you the eviction conversation for free. Ask it early.
- Does it need to survive a restart? — usually no, but ask. If yes you get an append-only log and a snapshot, which is a whole extra section you can control the size of.
- How precise must expiry be? — this is the good question. “Is it acceptable that a key which expired 200ms ago is still using memory, as long as nobody can read it?” If they say yes — and they will — you have just been handed permission to build the lazy plus sampled design.
Do not let TTL become an afterthought
Candidates build get, set and delete in eight minutes, feel good, and then bolt TTL on at minute forty with a Timer. Build the Entry with expiresAt in it from the first line. TTL is not a feature of this problem — it is the problem, and every design decision that follows is downstream of how you represent it.
Step 2 · A TTL is an instant, not a countdown
The user says “expire this in 5 seconds.” You store expiresAt = clock.nowMillis() + 5000 — one addition, once, at write time. From then on the question “is this key alive?” is a single comparison: now < expiresAt. No arithmetic runs in between. No thread wakes up. The key does not know how long it has left and does not need to.
The alternative — a secondsLeft field, or a Timer/ScheduledFuture per key — feels more direct and is the single most common way to lose this round.
Which clock, though?
Use a monotonic source where you can (System.nanoTime, time.monotonic()), because wall-clock time can jump backwards when NTP corrects it and a key would then un-expire. In the interview, wrap whichever you pick behind a Clock interface and move on — the point you are making is that the store never calls a static time function directly. That is plain Dependency Injection & IoC, and here it is also the only thing that makes TTL testable without sleeping.
Step 3 · Expired is not deleted — the idea this whole problem turns on
Follow one key. At t=0 you call set("k", "v", ttl=5s), so expiresAt = 5000. At t=5000 the key expires. Ask yourself what code runs at that moment. The answer is: none. There is no timer, no callback, no thread watching. The clock simply moves past a number stored in a field.
So at t=6000 the key is still in the map. size() still counts it. Its bytes are still resident. It is logically gone and physically present, and it stays in that state — possibly for hours — until somebody looks at it.
size() row. It says 1 for four seconds after the key stopped being readable. Correctness and memory are two different questions here, and this figure is the moment they separate.The sentence that wins this round
“Nothing happens at the expiry instant. The key is unreadable from that moment because every read checks expiresAt, but it does not leave the map until either somebody reads it or the sweeper samples it.” Say that in the first ten minutes and the rest of the hour is a conversation between equals.
Step 4 · Two expiry strategies, and real systems use both
Lazy expiry is the one you get almost for free. Every get already looks the key up; adding one comparison to that lookup is free in every sense that matters. If expiresAt has passed, you delete the key right there and report a miss. The caller can never observe a stale value, and you never spend a single cycle on keys nobody asks about.
And that last clause is also the hole. Consider a key called report:2019-q3 with a one-hour TTL, written once and never read again. Its hour passes. Nobody calls get. Nothing deletes it. It sits in memory until the process restarts — and a workload full of such keys will run the server out of RAM while size() cheerfully reports a number that is mostly ghosts.
So the sweeper has to run. The naive sweeper walks every key, checks every expiresAt, and deletes what is dead. On a store with ten million keys that walk takes long enough to be visible to every client, and it does it over and over to find the handful of keys that died since last time. You do not need a complete answer, you need a statistical one.
/**
* One pass of active expiry. Bounded work, then it returns.
* A scheduler calls this every 100ms; it never walks the whole keyspace.
*/
int sweepOnce() {
int totalRemoved = 0;
for (int round = 0; round < MAX_ROUNDS; round++) { // hard cap, so a pass always ends
List<String> sample = randomKeysWithTtl(SAMPLE_SIZE); // 20
if (sample.isEmpty()) break;
int expired = 0;
lock.writeLock().lock();
try {
long now = clock.nowMillis();
for (String key : sample) {
Entry e = map.get(key);
if (e != null && e.isExpired(now)) { map.remove(key); expired++; }
}
} finally {
lock.writeLock().unlock();
}
totalRemoved += expired;
// "was more than 25% of the sample dead?" expired/size > 0.25 without floats
if (expired * 4 <= sample.size()) break; // sparse → stop, sleep, try later
}
return totalRemoved;
}Why sample only the keys that have a TTL
Most stores hold a mix: some keys expire, most do not. Sampling the whole keyspace would waste almost every draw on keys that can never be expired. Keep a second set — Redis calls it the expires dict — holding just the keys with a deadline, and sample from that. It costs one extra insert on setWithTtl and it makes the sweeper an order of magnitude more effective.
Step 5 · The API surface is the design
Write the method list on the board before you write any bodies. It takes two minutes, it is the artefact the interviewer will actually discuss with you, and two of the rows on it are where careless designs get caught.
set clears the TTL — and that has broken real systems
A session key is written with a 30-minute TTL. Later some unrelated code refreshes the value with a plain set(key, value) — and the session becomes immortal. The user is logged in forever and the memory is never reclaimed. Redis behaves exactly this way, which is why it grew a KEEPTTL option. Offer the same choice: set(key, value, keepTtl), defaulting to clearing, and say the sentence “a plain set makes the key permanent” out loud.
The second shaded row is ttl(key). There are genuinely three answers it can give, and they mean completely different things: “1,842 milliseconds left”, “this key exists and will never expire”, and “there is no such key.” Collapse two of those into one sentinel and the caller cannot tell a permanent key from a missing one — which is exactly the bug that makes people write if (ttl(k) == -1) recreate(k) and then wipe a key that was fine.
/** Three distinguishable states. No caller ever has to guess. */
sealed interface TtlResult {
record Remaining(long millis) implements TtlResult {}
record NoExpiry() implements TtlResult {} // key is there, no deadline
record NoSuchKey() implements TtlResult {} // key is absent OR expired
}
TtlResult ttl(String key) {
long now = clock.nowMillis();
lock.readLock().lock();
try {
Entry e = map.get(key);
if (e == null || e.isExpired(now)) return new TtlResult.NoSuchKey();
if (e.neverExpires()) return new TtlResult.NoExpiry();
return new TtlResult.Remaining(e.expiresAtMillis() - now);
} finally {
lock.readLock().unlock();
}
}
// Redis encodes the same three states as -2 (no key), -1 (no expiry), n (millis left).
// That works, but only because it is documented. A type says it without a manual.The keys(pattern) trap
keys("session:*") has to walk every key in the store, and in a single-threaded server it holds the whole thing still while it does. That is why Redis's own documentation tells you not to run KEYS in production and offers SCAN instead — a cursor: each call returns a few keys and a position to resume from, so the work is spread across many small calls. Mention it in one sentence: “I would ship keys() for the interview and a cursor-based scan() for production, because keys() is O(n) and blocking.”
Step 6 · Concurrency — where the lazy delete bites
A key-value store is read-heavy: many gets, few sets. That is the textbook case for a Read-Write locks — any number of readers together, or one writer alone. And here, unlike in Cache (LRU / LFU) where a get secretly rewrites the recency list and so a read lock buys you nothing, a get in a plain store really is a read. It looks up a key and compares two numbers. Nothing moves.
Except when the key it finds has expired. Then the read has to delete it — and deleting is a write. That is the one interesting concurrency moment in the whole problem, and it is worth being precise about.
- The 60-minute answer: one
ReentrantReadWriteLock,gettakes the read lock, and the lazy delete drops the read lock and takes the write lock with a re-check. Ten lines, correct, and you can explain it. - The tidier answer: a
ConcurrentHashMapandcomputeIfPresent(key, (k, e) -> e.isExpired(now) ? null : e). Returningnullfrom the remapping function removes the entry, so the check and the delete are one atomic step on that bin — no explicit lock at all. Related: Atomic operations & CAS. - The answer you must not give:
synchronizedon every method. It works, and it makes a read-heavy store serialise every singlegetbehind every other one. Background: Locks, Mutex, Semaphore. - How you scale past one lock: shard the keyspace.
shardFor(key) = shards[hash(key) & (N-1)], each shard holding its own map and its own lock. Keys in different shards never contend, so N writers can proceed at once.
The class diagram
Clock seam is the one that earns its keep in an interview — the eviction seam is the one to name and only build if there is time. Notation: Class diagrams.The memory bound — dying of space, not of time
A key can die two completely different deaths, and conflating them is a classic slip. Expiry is about time: this key had a deadline and the deadline passed. Eviction is about space: the store is full and something has to go, whether or not it had a deadline at all. Different trigger, different mechanism, different policy.
noeviction— refuse writes once the limit is hit. Correct for a store you are using as a database, infuriating for a cache. This should be your default answer, because it is the only one that never silently loses data.allkeys-lru— throw out the least recently used key, TTL or not. The right choice when the store is purely a cache. The machinery for it is the entire sibling problem — see Cache (LRU / LFU) — so here you just callpolicy.pickVictim()and move on.volatile-ttl— among the keys that already have a deadline, drop the one expiring soonest. Elegant, because you are killing something that was going to die anyway, and it never touches a key the user marked permanent.random/allkeys-random— pick a victim at random. Sounds lazy; it is genuinely reasonable when access has no locality, and it costs nothing to implement.
Bytes, not entries
maxEntries is fine for a whiteboard and wrong in production: 10,000 entries could be 4 MB or 4 GB. Real stores take a weigher — a function from an entry to its size — and evict while totalWeight > maxMemory. It is the same loop with a different comparison, and saying it takes ten seconds.
Durability — what survives a restart
An in-memory store loses everything when the process dies. Sometimes that is fine. When it is not, there are exactly two mechanisms, and real systems run both.
Atomic operations, and why they belong in the store
“Can two clients both increment a counter?” If the only API is get then set, the answer is no — client A reads 5, client B reads 5, both write 6, and one increment vanishes. This is the same lost-update shape as two gates grabbing one spot in Parking Lot, and the fix is the same: the read-modify-write has to happen inside the store, under its lock, as one operation.
incr(key, delta)— parse, add, store, return the new value, all under one write lock. One method, and it removes an entire class of client bug. Note that it must decide what a missing key means: treat it as 0, and say so.compareAndSet(key, expected, next)— write only if the current value is still what the caller last read. This is optimistic concurrency: the caller retries on failure instead of holding a lock. Same primitive as Atomic operations & CAS, one level up.- MULTI-style batching — queue several commands and run them back to back with nothing interleaved. Note the honest limitation: that gives you isolation, not rollback. If command three fails, commands one and two have already happened. Redis is explicit about this, and being explicit about it yourself reads very well.
The 60 minutes
get is the minimum viable version of this problem. Everything to the right of the green block is a bonus you narrate if you run out of time.The follow-ups
- “Support lists, sets and hashes, not just strings.” →
Entry.valuebecomes a sealed type (StringValue | ListValue | HashValue), commands validate the type and reject mismatches, and nothing else about the design changes — TTL, expiry, locking and eviction are all indifferent to what the value is. Say that last clause; it is the answer they are listening for. - “Notify me when a key expires.” → publish an event from the two places a key actually dies (the lazy delete and the sweeper) and let subscribers listen: Pub/Sub & Event-driven. Be honest that the notification fires when the key is reclaimed, not at the expiry instant — which is precisely the distinction the whole lesson is about.
- “Make it distributed.” → consistent hashing spreads keys across nodes so each key lives on exactly one, and each node runs this same design locally. Your
ReadWriteLocknow guarantees nothing across the cluster, TTLs depend on clocks that drift between machines, andkeys(pattern)becomes a fan-out to every node. Naming those three consequences is the answer. - “What if a very hot key expires and ten thousand requests miss at once?” → the thundering herd. One request rebuilds while the rest wait on the same future, or you refresh slightly before expiry. One sentence; it is a whole problem of its own.
- “How would you test TTL?” → a
FakeClock, and tests that read like sentences: set with ttl 5s, advance 4s, expect a hit; advance 2s more, expect a miss; assertsize()was still 1 before the get and 0 after. Not aThread.sleepin sight. This is the payoff for injecting the clock. - “What would you monitor?” → keys, expired-lazily, expired-actively, evicted, hit ratio, memory used against the limit, and the sweeper's sample-hit rate. That last one is the interesting one: if the sweeper keeps finding 25%+ expired every pass, it is losing the race and your TTLs are shorter than your reclaim rate.
How this round is lost
- A
TimerorScheduledFutureper key. It looks like the most direct translation of “expire in 5 seconds” and it does not survive contact with a million keys. This is the single most common way to fail this problem. - Storing a countdown instead of an instant. Now something must decrement it, and you have invented the timer problem in a different shape.
- Returning expired values. If
getdoes not checkexpiresAt, the TTL is decorative. The store is wrong in the most basic possible way and no amount of sweeper sophistication saves it. - A sweeper that scans every key. O(n) on a loop, holding a lock, on a store meant to answer in microseconds. Sample instead.
synchronizedon every method. Correct and slow, in a workload that is 95% reads — the one case where a read-write lock genuinely pays for itself.ttl()returning-1for both “no expiry” and “no such key”. Two very different facts, one indistinguishable answer, and a caller that cannot help but get it wrong.- Treating expiry and eviction as one mechanism. “When memory is full I delete the expired keys” is not an eviction policy — a store can be completely full of keys that are all perfectly alive.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A live store with a virtual clock you control. Press ➕ SET a few times to add keys with TTLs, then ⏩ +2s — rows go grey and struck-through but stay in the table, and the size() stat still counts them, because nothing ran at the moment they expired. Click a struck-through row to GET it: it returns (nil) and only then vanishes — that is lazy expiry, done by hand, one key at a time. Press 😴 Cold key to create a key nobody will ever read and watch the leaked counter climb, then 🧹 Sweep to see the sampling loop draw 20 keys, delete the expired ones, and decide on screen whether to go round again. 🔒 SET no-TTL makes an existing key's expiry vanish, and 📉 Fill memory kills a key for space while everything else here dies of time.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Fill the store and read the columns
Press ➕ SET three times. Each row shows the key, the value, the expiresAt instant (an absolute number on the virtual clock, not a countdown) and a ttl that ticks down as a derived value. Note that user:7 arrives with expiresAt = ∞ — a key with no deadline is not a special case, it is just a very large number.
The money moment — advance the clock
Press ⏩ +2s until the first key's ttl hits zero. The row goes grey and struck through and its pill reads expired-but-present. Now look at the size() stat: it has not changed. Nothing ran when that expiry passed. The key is unreadable and still resident, and it will stay that way as long as you leave it alone.
Delete it by looking at it
Click the struck-through row to GET it. It returns (nil), the row disappears at that moment, size() finally drops and misses ticks up. You just performed lazy expiry by hand. Do it to a second key and notice that the store did no work at all on any key you did not touch.
Watch a cold key leak
Press 😴 Cold key. It writes a key nobody will ever read and jumps the clock past its TTL. The leaked counter starts climbing and nothing stops it — press ⏩ +2s a few more times and watch it keep climbing. This is the failure mode of a lazy-only store, and it is the entire reason the next button exists.
Run the sampling sweeper
Press 🧹 Sweep. It draws up to 20 keys that have a TTL, highlights exactly those, deletes the expired ones, and then shows the decision on screen: something like 6/20 expired → 30% > 25% → sampling again. It loops while the sample stays dense and stops when it goes sparse. The leaked keys vanish, and the cost was proportional to the garbage found — not to the size of the store.
Make a TTL disappear
Pick a row that still has a live ttl and press 🔒 SET no-TTL. The value updates and the expiresAt column flips to ∞ — the deadline is gone and the key is now permanent. This is a real production bug: a session refreshed with a plain set never logs the user out and never frees its memory. The fix is a keepTtl flag, and knowing to offer it is the point.
Kill a key for space instead of time
Press 📉 Fill memory until the memory bar crosses maxMemory. A key is evicted — and read the explain line carefully: this key was alive. It did not run out of time, it ran out of room. Two different mechanisms, two different counters, and mixing them up is one of the ways this round is lost.
Build it from memory
Blank file, in this order: Clock interface with a FakeClock → Entry(value, expiresAtMillis) with an isExpired(now) → a map plus a get that checks expiresAt, deletes on expiry, and returns a miss → setWithTtl / expire / a three-state ttl → a sweepOnce() that samples 20 and repeats above 25%. Then a main() that sets a key with a 5s TTL, advances the fake clock by 6s, prints size() (still 1), calls get (nil), and prints size() again (0). If that last pair of numbers is not 1 then 0, you have not built this problem — you have built a hash map.
In practice
When to use it — and what trips people up
The shape you just learned
Strip the keys and values away and what is left is: state with a deadline, reclaimed lazily on access and swept probabilistically in the background. Once you have seen it here you start seeing it everywhere, and the same two questions apply every time — who checks the deadline? and who reclaims the ones nobody checks?
- Session stores. A logged-in session is exactly this: a value with a sliding deadline. The only thing that logs a user out is a request arriving and finding the deadline passed.
- Idempotency keys. “I have seen this request id before” is a set with a TTL. The lazy check happens on the next duplicate; the sweeper is what stops the set growing without limit.
- Rate-limiter counters. A per-user counter with a window is a key with a TTL. There is no timer resetting anybody's quota — the next request notices the window rolled over.
- DNS and HTTP caches. A record carries a TTL and the resolver checks it on lookup. Nothing purges your DNS cache at the instant a record expires.
- Feature-flag and config caches. A value with a refresh deadline, checked on read. Same design, smaller vocabulary.
- Lock leases. A distributed lock with a TTL is a key that dies on its own so a crashed holder cannot block everybody forever — and “expired but not yet reclaimed” is precisely the dangerous window you have to reason about.
The two-sentence version to say out loud
“A TTL is an absolute instant stored on the entry, so no timers exist. Reads check that instant and delete on the way past, and a background sampler catches the keys nobody reads — which means a key can be logically gone while still occupying memory, and that is a deliberate trade, not a bug.”
Where this design stops working
- When expiry must be exact to the millisecond. Nothing here fires at the deadline. If a key expiring must trigger an action at that instant — a scheduled job, a billing event — you need a real timer wheel or a priority queue of deadlines, and that is a different design with a different cost.
- When the store spans machines. Your
ReadWriteLockprotects one process. Across a cluster, TTLs depend on clocks that drift,keys(pattern)becomes a fan-out, and “delete if expired” becomes a distributed agreement problem. - When the data must not be lost. In-memory means gone on restart, and both durability mechanisms have a loss window you have to state as a number. If the answer must be zero, you are designing a database, not a cache.
- When values get large and structured. The design does not change, but the costs do: eviction should count bytes rather than entries, and a single huge value can blow the memory bound on its own.
- When the read/write mix flips. The read-write lock pays for itself only while reads dominate. A write-heavy store gets everything serialised anyway, and you should shard from the start — or reach for a lock-free map.
If you only remember one thing
Expired is not deleted. A key stops being readable at its expiry instant and stops being resident only when a reader or a sweeper touches it. Every design decision in this problem — the absolute instant, the lazy check, the sampling loop, the honest size() — exists because of the gap between those two moments.
What it gives you
- Storing an absolute expiresAt costs eight bytes per key and zero running machinery — a million keys still means zero timers and zero background ticking.
- Lazy expiry makes it impossible for a caller to read a stale value, and it costs one integer comparison on a lookup you were already doing.
- The sampling sweeper bounds its own work: the CPU it spends tracks the amount of garbage it keeps finding, not the number of keys in the store.
- A ReadWriteLock genuinely pays here, because in a plain key-value store a get really is a read — unlike a recency-ordered cache, where every get mutates.
- Injecting a Clock turns every TTL test into three fast, deterministic lines instead of a suite full of Thread.sleep.
Common mistakes
- Expired keys keep holding memory until something looks at them, so size() and real memory usage can disagree for an unbounded stretch of time.
- The sweeper's guarantee is only probabilistic — roughly a quarter of the keys with a TTL may be expired-but-present at any instant, and there is no way to make it zero cheaply.
- The lazy delete forces a read to become a write, and since read-write locks cannot upgrade in place, that path needs a release, a re-acquire and a re-check that are easy to get subtly wrong.
- A plain set clearing the TTL is a genuine footgun — it matches Redis, and it has silently made session keys immortal in real systems.
- keys(pattern) is O(n) and blocks every other operation while it runs, which is why production systems need a second, cursor-based API you have not built.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/** Time behind an interface — the one seam that makes TTL testable without sleeping. */
interface Clock { long nowMillis(); }
final class SystemClock implements Clock {
public long nowMillis() { return System.currentTimeMillis(); }
}
/** A clock you move by hand. Every TTL test in this file runs in microseconds because of it. */
final class FakeClock implements Clock {
private long now;
FakeClock(long startMillis) { this.now = startMillis; }
public long nowMillis() { return now; }
void advance(long millis) { now += millis; }
}
/**
* Immutable value object. Holds an ABSOLUTE instant, never a countdown:
* nothing has to tick it, and a million keys still means zero running timers.
*/
final class Entry {
static final long NEVER = Long.MAX_VALUE;
private final String value;
private final long expiresAtMillis;
Entry(String value, long expiresAtMillis) {
this.value = Objects.requireNonNull(value);
this.expiresAtMillis = expiresAtMillis;
}
String value() { return value; }
long expiresAtMillis() { return expiresAtMillis; }
boolean neverExpires() { return expiresAtMillis == NEVER; }
boolean isExpired(long now) { return now >= expiresAtMillis; }
}
/** Three genuinely different answers. A single int that means all three is a bad API. */
sealed interface TtlResult {
record Remaining(long millis) implements TtlResult {
public String toString() { return millis + "ms left"; }
}
record NoExpiry() implements TtlResult {
public String toString() { return "no expiry"; }
}
record NoSuchKey() implements TtlResult {
public String toString() { return "no such key"; }
}
}
final class KeyValueStore {
private static final int SAMPLE_SIZE = 20;
private static final int MAX_SWEEP_ROUNDS = 16; // so one pass ALWAYS terminates
private final Map<String, Entry> map = new HashMap<>();
// Only the keys that HAVE a deadline. The sweeper samples from here, never from
// the whole keyspace — otherwise almost every draw is wasted on permanent keys.
private final List<String> volatileKeys = new ArrayList<>();
private final Map<String, Integer> volatileIndex = new HashMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Clock clock;
private final int maxEntries;
private final Random random = new Random(7);
private final LongAdder hits = new LongAdder(), misses = new LongAdder();
private final LongAdder lazyExpired = new LongAdder(), activeExpired = new LongAdder();
private final LongAdder evicted = new LongAdder();
KeyValueStore(Clock clock, int maxEntries) {
this.clock = clock;
this.maxEntries = maxEntries;
}
/* ------------------------------------------------------------------ reads */
/**
* A get really IS a read here — until it finds an expired key, and then it must WRITE.
* Java's ReadWriteLock cannot upgrade in place, so we drop the read lock, take the
* write lock, and RE-CHECK: another thread may have written a fresh value in the gap.
*/
Optional<String> get(String key) {
long now = clock.nowMillis();
lock.readLock().lock();
try {
Entry e = map.get(key);
if (e == null) { misses.increment(); return Optional.empty(); }
if (!e.isExpired(now)) { hits.increment(); return Optional.of(e.value()); }
} finally {
lock.readLock().unlock();
}
lock.writeLock().lock();
try {
Entry again = map.get(key);
if (again != null && again.isExpired(clock.nowMillis())) {
removeLocked(key);
lazyExpired.increment();
}
misses.increment(); // this call misses either way — it saw a dead key
return Optional.empty();
} finally {
lock.writeLock().unlock();
}
}
boolean exists(String key) { return get(key).isPresent(); } // SAME expiry rule as get
/** Three states, never overloaded onto one number. */
TtlResult ttl(String key) {
long now = clock.nowMillis();
lock.readLock().lock();
try {
Entry e = map.get(key);
if (e == null || e.isExpired(now)) return new TtlResult.NoSuchKey();
if (e.neverExpires()) return new TtlResult.NoExpiry();
return new TtlResult.Remaining(e.expiresAtMillis() - now);
} finally {
lock.readLock().unlock();
}
}
/** The PHYSICAL entry count — expired-but-not-yet-reclaimed keys are included, on purpose. */
int size() {
lock.readLock().lock();
try { return map.size(); } finally { lock.readLock().unlock(); }
}
/** What a caller could actually read right now. Usually smaller than size(). */
int liveSize() {
long now = clock.nowMillis();
lock.readLock().lock();
try {
int n = 0;
for (Entry e : map.values()) if (!e.isExpired(now)) n++;
return n;
} finally { lock.readLock().unlock(); }
}
/** O(n), and it holds the lock the whole way. This is exactly why SCAN with a cursor exists. */
List<String> keys(String pattern) {
long now = clock.nowMillis();
lock.readLock().lock();
try {
List<String> out = new ArrayList<>();
for (Map.Entry<String, Entry> e : map.entrySet())
if (!e.getValue().isExpired(now) && glob(pattern, e.getKey())) out.add(e.getKey());
Collections.sort(out);
return out;
} finally { lock.readLock().unlock(); }
}
/* ----------------------------------------------------------------- writes */
/** Plain set CLEARS any existing deadline — the key becomes permanent. Real stores do this too. */
void set(String key, String value) { set(key, value, false); }
void set(String key, String value, boolean keepTtl) {
lock.writeLock().lock();
try {
Entry old = map.get(key);
long expiresAt = Entry.NEVER;
if (keepTtl && old != null && !old.isExpired(clock.nowMillis()))
expiresAt = old.expiresAtMillis();
putLocked(key, new Entry(value, expiresAt));
evictIfNeededLocked();
} finally { lock.writeLock().unlock(); }
}
void setWithTtl(String key, String value, long ttlMillis) {
lock.writeLock().lock();
try {
putLocked(key, new Entry(value, clock.nowMillis() + ttlMillis)); // ONE addition, once
evictIfNeededLocked();
} finally { lock.writeLock().unlock(); }
}
boolean delete(String key) {
long now = clock.nowMillis();
lock.writeLock().lock();
try {
Entry e = map.get(key);
removeLocked(key);
return e != null && !e.isExpired(now); // do not claim to have deleted a ghost
} finally { lock.writeLock().unlock(); }
}
boolean expire(String key, long ttlMillis) {
lock.writeLock().lock();
try {
long now = clock.nowMillis();
Entry e = map.get(key);
if (e == null || e.isExpired(now)) return false;
putLocked(key, new Entry(e.value(), now + ttlMillis));
return true;
} finally { lock.writeLock().unlock(); }
}
boolean persist(String key) {
lock.writeLock().lock();
try {
long now = clock.nowMillis();
Entry e = map.get(key);
if (e == null || e.isExpired(now) || e.neverExpires()) return false;
putLocked(key, new Entry(e.value(), Entry.NEVER));
return true;
} finally { lock.writeLock().unlock(); }
}
/* -------------------------------------------------- atomic read-modify-write */
/** Two clients doing get-then-set would lose an increment. Doing it inside the store cannot. */
long incr(String key, long delta) {
lock.writeLock().lock();
try {
long now = clock.nowMillis();
Entry e = map.get(key);
boolean live = e != null && !e.isExpired(now);
long current = live ? Long.parseLong(e.value()) : 0; // missing key counts as 0
long next = current + delta;
putLocked(key, new Entry(Long.toString(next), live ? e.expiresAtMillis() : Entry.NEVER));
return next;
} finally { lock.writeLock().unlock(); }
}
/** Optimistic concurrency: write only if nobody changed it since you read it. */
boolean compareAndSet(String key, String expected, String next) {
lock.writeLock().lock();
try {
long now = clock.nowMillis();
Entry e = map.get(key);
if (e == null || e.isExpired(now) || !e.value().equals(expected)) return false;
putLocked(key, new Entry(next, e.expiresAtMillis()));
return true;
} finally { lock.writeLock().unlock(); }
}
/* ---------------------------------------------------------- active expiry */
/**
* One pass of the sweeper. Draw 20 random keys that have a TTL, delete the dead ones,
* and go again only while the sample stays dense. Bounded work, then it returns.
*/
int sweepOnce() {
int totalRemoved = 0;
for (int round = 0; round < MAX_SWEEP_ROUNDS; round++) {
List<String> sample;
lock.readLock().lock();
try { sample = sampleVolatileLocked(); } finally { lock.readLock().unlock(); }
if (sample.isEmpty()) break;
int expiredInSample = 0;
lock.writeLock().lock();
try {
long now = clock.nowMillis();
for (String key : sample) {
Entry e = map.get(key);
if (e != null && e.isExpired(now)) { removeLocked(key); expiredInSample++; }
}
} finally { lock.writeLock().unlock(); }
totalRemoved += expiredInSample;
activeExpired.add(expiredInSample);
// more than 25% dead? go again immediately. Integer form of expired/n > 0.25
if (expiredInSample * 4 <= sample.size()) break;
}
return totalRemoved;
}
private List<String> sampleVolatileLocked() {
int n = Math.min(SAMPLE_SIZE, volatileKeys.size());
List<String> out = new ArrayList<>(n);
for (int i = 0; i < n; i++) out.add(volatileKeys.get(random.nextInt(volatileKeys.size())));
return out;
}
/* ------------------------------------------------------------- eviction */
/**
* Dying of SPACE, not of time. Policy here is volatile-ttl: among keys that already
* have a deadline, drop the one expiring soonest. The full machinery of allkeys-lru
* is a problem of its own — this store only needs a pickVictim() seam.
*/
private void evictIfNeededLocked() {
while (map.size() > maxEntries) {
String victim = null;
long soonest = Long.MAX_VALUE;
for (String key : volatileKeys) {
Entry e = map.get(key);
if (e != null && e.expiresAtMillis() < soonest) { soonest = e.expiresAtMillis(); victim = key; }
}
if (victim == null) victim = map.keySet().iterator().next(); // nothing volatile: random
removeLocked(victim);
evicted.increment();
}
}
/* -------------------------------------------------------------- internals */
private void putLocked(String key, Entry e) {
map.put(key, e);
if (e.neverExpires()) unmarkVolatile(key); else markVolatile(key);
}
private void removeLocked(String key) {
map.remove(key);
unmarkVolatile(key);
}
/** List + index, so a random pick and a removal are both O(1). */
private void markVolatile(String key) {
if (volatileIndex.containsKey(key)) return;
volatileIndex.put(key, volatileKeys.size());
volatileKeys.add(key);
}
private void unmarkVolatile(String key) {
Integer i = volatileIndex.remove(key);
if (i == null) return;
int last = volatileKeys.size() - 1;
if (i != last) {
String moved = volatileKeys.get(last);
volatileKeys.set(i, moved);
volatileIndex.put(moved, i);
}
volatileKeys.remove(last);
}
/** One-star glob, which is all keys(pattern) ever really needs in an interview. */
private static boolean glob(String pattern, String key) {
int star = pattern.indexOf('*');
if (star < 0) return pattern.equals(key);
String head = pattern.substring(0, star), tail = pattern.substring(star + 1);
return key.length() >= head.length() + tail.length()
&& key.startsWith(head) && key.endsWith(tail);
}
String stats() {
return "keys=" + size() + " live=" + liveSize()
+ " hits=" + hits.sum() + " misses=" + misses.sum()
+ " expired-lazily=" + lazyExpired.sum()
+ " expired-actively=" + activeExpired.sum()
+ " evicted=" + evicted.sum();
}
}
public class Main {
public static void main(String[] args) {
FakeClock clock = new FakeClock(0);
KeyValueStore store = new KeyValueStore(clock, 100);
store.setWithTtl("session:1", "abc", 5_000);
store.set("user:7", "nina"); // no deadline — permanent
System.out.println("t=0 get(session:1) = " + store.get("session:1").orElse("(nil)"));
System.out.println("t=0 size()=" + store.size() + " live=" + store.liveSize());
clock.advance(6_000); // one second past the deadline
System.out.println();
System.out.println("t=6000 the key expired 1s ago. NOTHING RAN.");
System.out.println("t=6000 size()=" + store.size() + " live=" + store.liveSize() + " <- still counted");
System.out.println("t=6000 get(session:1) = " + store.get("session:1").orElse("(nil)"));
System.out.println("t=6000 size()=" + store.size() + " <- the GET removed it (lazy expiry)");
store.setWithTtl("report:2019", "...", 1_000); // a key nobody will ever read
clock.advance(60_000);
System.out.println();
System.out.println("t=66000 cold key expired 59s ago, never read: size()=" + store.size());
System.out.println("t=66000 sweeper reclaimed " + store.sweepOnce() + " key(s), size()=" + store.size());
store.setWithTtl("cart:42", "[a]", 30_000);
System.out.println();
System.out.println("ttl(cart:42) = " + store.ttl("cart:42"));
System.out.println("ttl(user:7) = " + store.ttl("user:7"));
System.out.println("ttl(nope) = " + store.ttl("nope"));
store.set("cart:42", "[a,b]"); // plain set — the TTL is gone
System.out.println("after a plain set, ttl(cart:42) = " + store.ttl("cart:42"));
store.expire("cart:42", 30_000);
store.set("cart:42", "[a,b,c]", true); // keepTtl
System.out.println("with keepTtl=true, ttl(cart:42) = " + store.ttl("cart:42"));
System.out.println();
System.out.println("incr(visits) x3 = " + store.incr("visits", 1) + ", "
+ store.incr("visits", 1) + ", "
+ store.incr("visits", 1));
System.out.println("cas(visits, 3 -> 10) = " + store.compareAndSet("visits", "3", "10"));
System.out.println("cas(visits, 3 -> 99) = " + store.compareAndSet("visits", "3", "99") + " (stale)");
System.out.println();
System.out.println("keys(\"c*\") = " + store.keys("c*"));
System.out.println(store.stats());
}
}
/* -------------------------- expected output --------------------------
t=0 get(session:1) = abc
t=0 size()=2 live=2
t=6000 the key expired 1s ago. NOTHING RAN.
t=6000 size()=2 live=1 <- still counted
t=6000 get(session:1) = (nil)
t=6000 size()=1 <- the GET removed it (lazy expiry)
t=66000 cold key expired 59s ago, never read: size()=2
t=66000 sweeper reclaimed 1 key(s), size()=1
ttl(cart:42) = 30000ms left
ttl(user:7) = no expiry
ttl(nope) = no such key
after a plain set, ttl(cart:42) = no expiry
with keepTtl=true, ttl(cart:42) = 30000ms left
incr(visits) x3 = 1, 2, 3
cas(visits, 3 -> 10) = true
cas(visits, 3 -> 99) = false (stale)
keys("c*") = [cart:42]
keys=3 live=3 hits=1 misses=1 expired-lazily=1 expired-actively=1 evicted=0
--------------------------------------------------------------------- */References & further reading
7 sources- Docsredis.io
Redis — EXPIRE, and “how Redis expires keys”
The section at the bottom describes the exact lazy-plus-sampled algorithm this lesson is built on, including the 20-key sample and the 25% threshold.
- Docsredis.io
Redis — key eviction and the maxmemory policies
noeviction, allkeys-lru, volatile-ttl and the rest, with the crucial framing that eviction is about space while expiry is about time.
- Docsredis.io
Redis — SCAN, and why KEYS is dangerous
The cursor contract, and the guarantees a partial scan can and cannot give you. Read this before you claim keys(pattern) is fine.
- Docsredis.io
Redis persistence — AOF versus RDB snapshots
The append-only log and the periodic snapshot, side by side, with the fsync policies and the exact size of the window you can lose.
- Articlegithub.com
Memcached — Overview (the lazy-expiry design)
The other end of the spectrum: no active expirer at all, expiry checked on fetch, and memory reclaimed by the slab allocator instead. A useful contrast.
- Docsdocs.oracle.com
ReentrantReadWriteLock — Java API docs
Read the “Lock downgrading” note in particular — it states plainly that a read lock cannot be upgraded to a write lock, which is why the lazy delete needs a re-check.
- Book
Designing Data-Intensive Applications — Martin Kleppmann
Chapter 3 on log-structured storage and compaction is the long version of this lesson's durability section, and chapter 8 explains why clocks make distributed TTLs hard.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
How should a key's TTL be stored?
question 02 / 08
A key is set at t=0 with a 5-second TTL. At t=8 nobody has touched it. What does size() return?
question 03 / 08
What is the specific failure mode of a store that only does lazy expiry?
question 04 / 08
Why does a background expirer sample around 20 random keys instead of scanning them all?
question 05 / 08
What must ttl(key) be able to express?
question 06 / 08
A key has 20 minutes left on its TTL. A caller does a plain set(key, newValue). What happens to the deadline?
question 07 / 08
Why does a ReadWriteLock help in a plain key-value store, when it does not help in an LRU cache — and what is the catch?
question 08 / 08
The store hits its memory limit. Every key in it is alive and none has a TTL. What should happen?
0/8 answered