Engineering Atlas › System Design Fundamentals

Key-value stores and their sweet spot

15 min read · last reviewed 2026-10-17

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

In one minute

How it works

The narrow contract

The API is a map from short byte-string keys to values from a counter to a few hundred kilobytes. Because the store never interprets the value, it can place any key on any machine by hashing it, replicate it blindly, and serve it at the same cost every time. This is the "finding things fast" problem shape in its purest form. It also shows up as a DNS resolver's cache, Git's content-addressed object database, and an LLM server's prefix cache keyed by a hash of the prompt tokens.

Hashed versus ordered

A hash store (Memcached, the Redis keyspace, Bitcask) maps each key to a slot. Lookups are O(1), but keys sit in no useful order, so "every key starting with user:42:" means scanning everything. An ordered store (RocksDB, LMDB, bbolt, etcd, FoundationDB, DynamoDB's sort key) keeps keys sorted, so a prefix or range is one seek followed by a sequential read. Ordered stores let you build structure into the key: order/2026-10-16/000187 makes "today's orders" a range scan, and FoundationDB's tuple layer encodes (tenant, table, id) into bytes that sort the way the tuple does (FoundationDB docs).

Key design is the schema

With no secondary indexes, every access pattern needs a key that answers it. To look a user up by email as well as by id, you write two keys, user/id/42 holding the record and user/email/ana@x.io holding 42, and you are now responsible for keeping them consistent. The Indexing lesson shows the database doing this for you; here you do it by hand, ideally in one transaction or with a repair job. Hot keys are the other half of key design: a key that receives a large share of traffic lands on one partition no matter how many you have (see Hot Keys).

Families of stores

In-memory stores trade durability for sub-millisecond latency (see Redis). Check the licence as well as the engine: Redis moved to source-available licences in 2024, the Linux Foundation forked Valkey from Redis 7.2.4 under BSD (Linux Foundation), and Redis 8 added AGPLv3 as an option (Redis). Embedded engines are libraries: RocksDB is an LSM tree (see LSM Trees), LMDB and bbolt are copy-on-write B+ trees read through mmap (bbolt README), and Bitcask is a log plus an in-memory hash table. Distributed stores partition and replicate the keyspace: DynamoDB with per-partition leaders, etcd with Raft over one small keyspace, Dynamo-style systems with leaderless quorums (see Design a Key-Value Store). Many richer databases are key-value stores underneath: CockroachDB and TiKV map SQL rows onto ordered keys.

Conditional writes

A plain put loses updates when two clients read, modify and write the same key. Every serious store therefore offers a compare-and-set: Redis WATCH with MULTI/EXEC or SET key value NX, DynamoDB's ConditionExpression, etcd transactions that compare a key's mod_revision. This is the contention problem shape, solved by optimistic concurrency on a single key.

Down the stack

Layer 1The contract

Per key, a store promises either linearizability (etcd, FoundationDB, DynamoDB strongly consistent reads: a read returns the latest acknowledged write) or eventual consistency (Dynamo-style stores, DynamoDB's default reads, Redis replicas: a read may return an older value for a while). Durability is a separate dial: Memcached promises none, Redis with appendfsync everysec, the default AOF policy, may lose about one second of writes on a crash (Redis docs), and etcd acknowledges only after a majority of members have the entry on disk. Limits are part of the contract: DynamoDB items are capped at 400 KB, etcd requests at 1.5 MiB with a 2 GiB default storage quota and a suggested maximum of 8 GiB (etcd docs), and Redis strings at 512 MB. Multi-key atomicity is never free: absent, limited to keys on one shard (Redis Cluster hash tags, cluster spec), or a transaction with a size cap (100 items and 4 MB in DynamoDB, AWS docs).

Layer 2The mechanism

Bitcask, Riak's original engine, is the simplest durable design. Every write appends a record to the active data file: CRC, timestamp, key size, value size, key, value; a delete appends a tombstone. An in-memory hash table, the keydir, maps each key to (file_id, value_sz, value_pos, tstamp) of its latest record, so a read is one hash lookup and one disk read (Bitcask paper). A background merge rewrites only live records into new files, plus a hint file of keys and positions so a restart rebuilds the keydir without reading values. On startup, a record whose CRC does not match is a torn write from a crash, and the tail after it is discarded.

LMDB and bbolt keep one file holding a B+ tree, updated copy-on-write: a write transaction writes changed pages to free space, then flips a meta page to the new root. Readers keep the old root and never block; there is one writer at a time, and old pages cannot be reused while a long read transaction still holds them, so a stuck reader makes the file grow. etcd stores every revision of every key in bbolt, keyed by revision number, with an in-memory B-tree from user keys to revisions, which is how it serves watches and historical reads (etcd docs).

Layer 3The machine

An in-memory hash lookup costs about 100 ns, mostly one or two cache misses. Over the network the same GET costs a datacenter round trip of 100 to 500 µs, which is why Redis clients pipeline: 100 commands share one round trip and one read/write syscall pair. An NVMe SSD serves a 4 KB random read in roughly 10 to 100 µs, so Bitcask's one read per get is fast while the keydir fits in RAM; at the key plus a few dozen bytes per entry, 500 million keys need tens of gigabytes.

Writes hit the log sequentially, and durability is decided by fsync: a millisecond or more on a consumer SSD, tens of microseconds on drives with power-loss protection. Stores batch many writes per fsync (group commit). LMDB maps its file with mmap, so a cold read is a page fault and its cache is the OS page cache.

Why it's built this way

  1. Most hot data is fetched by an exact identifier, so a structure that answers only that question can be faster and simpler than a general query engine.
  2. Random writes are slow and sequential appends are fast on every medium, so durable engines log first (Bitcask, LSM) or copy-on-write (LMDB) instead of updating in place.
  3. Logs accumulate dead records, so engines merge or compact in the background, and must checksum records because a crash can tear the last write.
  4. An opaque value with a hashable key can live anywhere, so partitioning and replication are mechanical; the price is that every other access path must be a key you maintain.

Diagram

flowchart LR
  P["put(k, v)"] --> L["Append record: crc, key, value"]
  L --> K["keydir: k to offset"]
  G["get(k)"] --> K
  K --> R["One read at offset"]
  D["delete(k)"] --> T["Append tombstone, drop from keydir"]
  M["merge"] --> N["New log: live records only"]
  N --> K
  C["Crash restart"] --> S["Scan log, stop at bad CRC"]
  S --> K

Worked example

A login service keeps 40 million active sessions. Each key is sess: plus a 32-character token (37 bytes) and each value is about 300 bytes of JSON. Traffic is 60,000 reads and 4,000 writes a second, with a 30-day sliding expiry.

In Redis, each key costs key and value plus roughly 50 to 80 bytes for the dictionary entry, object header and expiry, about 420 bytes. 40 million sessions need about 17 GB: three shards of about 6 GB leave room for replication buffers and snapshot forks, and 64,000 operations a second is light for three shards.

On a Bitcask-style disk store, the keydir holds 37 bytes of key plus about 40 bytes of overhead per entry, about 3 GB of RAM, and the values live on SSD: 40 million times 340 bytes is 14 GB of live data. With 4,000 writes a second and sessions updated on activity, the log grows by about 1.4 MB a second, around 117 GB a day, almost all of it garbage. Merging when dead bytes exceed 50% of a file keeps disk usage near 30 GB and costs a steady background read and write stream.

Sessions are a natural key-value fit. The trap arrives when someone asks for "all sessions for user 42" to log a user out everywhere. With only token keys, that is a full scan. The fix is a second key per user, user_sess:42, holding a set of tokens, updated in the same transaction as the session itself.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
In-memory hash (Memcached, Redis)Sub-millisecond, simple, rich types in RedisRAM cost, weak durability by defaultCaches, sessions, counters, rate limits
Log plus hash index (Bitcask)One disk read per get, sequential writes, simple recoveryAll keys must fit in RAM, no range scansMany writes, point lookups, moderate key count
LSM tree (RocksDB)High write throughput, ordered, compresses wellRead and write amplification, compaction tuningWrite-heavy embedded storage, engines under databases
Copy-on-write B+ tree (LMDB, bbolt)Fast lock-free reads, crash-safe without a logOne writer, write amplification on random updatesRead-heavy embedded data, config and metadata
Distributed service (DynamoDB, etcd)Replication, failover and scaling handled for youNetwork latency, item and request limits, cost per requestShared state across services and regions

Pitfalls in production

Leadership lens

Check yourself

  1. Why can Bitcask serve any get with a single disk read, and what limits how much data it can hold?

    Show answer

    The in-memory keydir gives the exact file and offset of the latest value, so the read goes straight there. Every key must fit in RAM, so key count, not value size, sets the limit.

  2. You need "all orders for customer 42 in the last week". What does each kind of store require?

    Show answer

    An ordered store can hold keys like cust/42/order/2026-10-16/... and answer with one prefix range scan. A hash store needs an extra key per customer listing order ids, maintained on every write.

  3. Why must a log-structured store checksum every record?

    Show answer

    A crash can leave the last record partly written. Without a checksum, recovery would load garbage as a valid value; with one, it detects the torn record and discards the tail.

  4. Two workers increment the same counter with get then put. What goes wrong and what are the fixes?

    Show answer

    Both read 10 and both write 11, losing an increment. Use an atomic server-side increment or a compare-and-set that retries when the value changed.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

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