Key-Value Modeling: Keys, TTLs, Caching, and Atomic Operations
A key-value store exposes a simple idea:
key -> value
The simplicity is powerful because the database can optimize direct lookup very well. The trade-off is that application design must make the important access paths obvious in the key.
A key-value model is a good fit when the application usually knows the key before it asks for the value.
Start from the operation
Examples:
session token -> session state
user ID -> profile cache
rate key -> request counter
cart ID -> shopping cart
job ID -> job state
These are naturally key-oriented.
Less natural:
"find every session whose user lives in Berlin"
That is not a direct key lookup.
A secondary index or another database may be needed.
Key design is data modeling
A key is not merely a string.
It encodes:
- namespace,
- identity,
- sometimes tenant,
- sometimes time bucket,
- sometimes version.
Example:
session:8ac17...
Example:
user:42:cart
Example:
rate:user:42:2026-10-01T15
A good key is:
- deterministic,
- unambiguous,
- reasonably compact,
- stable across application versions.
Namespaces
Prefix related key families:
session:<token>
cart:<user_id>
profile:<user_id>
rate:<user_id>:<window>
This improves:
- debugging,
- metrics,
- bulk maintenance,
- ownership clarity.
Avoid using a prefix as a substitute for a proper authorization boundary.
Avoid ambiguous concatenation
Bad:
tenant + user
12 + 34 -> 1234
1 + 234 -> 1234
Use separators or structured encoding:
tenant:12:user:34
tenant:1:user:234
Tenant-aware keys
For multi-tenant systems:
tenant:acme:session:...
tenant:beta:session:...
This makes tenant identity explicit.
It does not replace permission checks.
Value shape
A value can be:
- string,
- serialized JSON,
- binary object,
- specialized data structure.
The value format has a schema even if the database treats it as opaque bytes.
Example JSON value:
{
"user_id": 42,
"currency": "EUR",
"items": [
{"sku": "DB-101", "qty": 1}
]
}
The application still needs versioning and validation.
Redis as a concrete example
Redis provides richer server-side data structures:
- strings,
- hashes,
- sets,
- sorted sets,
- lists,
- streams.
The engine chapter contains a complete Redis note:
This chapter focuses on modeling choices.
String
Good for:
- opaque cached JSON,
- counters,
- tokens,
- simple values.
Example:
SET feature:checkout:v2 enabled
Resulting key-value row (the command replies OK):
| key | value |
|---|---|
feature:checkout:v2 |
enabled |
Counter:
INCR article:42:views
Starting key-value row and result:
| key | value before | INCR result |
value after |
|---|---|---|---|
article:42:views |
9 | 10 | 10 |
Hash
A hash stores fields under one key.
HSET user:42
name "Alice"
plan "pro"
country "DE"
Resulting hash fields:
| key | field | value |
|---|---|---|
user:42 |
name | Alice |
user:42 |
plan | pro |
user:42 |
country | DE |
This can update one field without replacing the entire serialized value.
Set
A set represents unique membership.
SADD team:7:members 42 51 87
Resulting set members (Redis does not promise an order):
| key | member |
|---|---|
team:7:members |
42 |
team:7:members |
51 |
team:7:members |
87 |
Useful for:
- membership,
- deduplication,
- tags.
Sorted set
Members have scores.
ZADD leaderboard 1200 alice
ZADD leaderboard 1500 bob
Resulting sorted-set rows:
| key | member | score |
|---|---|---|
leaderboard |
alice | 1200 |
leaderboard |
bob | 1500 |
Useful for:
- rankings,
- priority queues,
- time-ordered scheduling.
TTL
Time-to-live is one of the most important key-value features.
Example:
SET session:abc <value> EX 3600
Resulting key state (the SET command replies OK):
| key | value | expiry |
|---|---|---|
session:abc |
<value> |
3600 seconds |
A session expires after one hour.
TTLs are natural for:
- sessions,
- verification codes,
- short-lived caches,
- rate-limit windows.
TTL is part of business semantics
Ask:
- what happens after expiration?
- can the value be rebuilt?
- does expiration create user-visible behavior?
- can several related keys expire at different times?
If the only copy of a critical payment state expires automatically, the data model is probably wrong.
Cache-aside
A common caching pattern:
application
β
βΌ
cache lookup
β
βββ hit -> return
β
βββ miss
β
βΌ
primary DB
β
βΌ
populate cache
The primary database remains authoritative.
Cache key composition
Suppose API response depends on:
- customer ID,
- language,
- currency.
Bad key:
customer:42
It ignores response dimensions.
Better:
customer:42:lang:de:currency:EUR
Cache keys must include every input that changes the result.
Cache invalidation
Suppose:
DB price = 49.90
cache = 49.90
Database changes to 59.90.
The cache is stale.
Strategies include:
Delete on write
update DB
β
βΌ
delete cache key
Next read reloads.
Short TTL
Staleness is bounded by expiration.
Event-driven invalidation
DB change
β
βΌ
event
β
βΌ
cache invalidator
Versioned key
product:42:v7
Change version to move callers to a new key.
No invalidation strategy is universally correct.
Cache consistency window
Document the tolerated staleness.
Example:
product description -> 5 minutes acceptable
inventory available -> 5 minutes unacceptable
Different fields may need different cache policies.
Cache stampede
If one popular key expires:
10,000 requests
β
βΌ
all see miss
β
βΌ
10,000 DB queries
Mitigations:
- request coalescing,
- jittered TTL,
- stale-while-revalidate,
- prewarming,
- lock/single-flight pattern.
TTL jitter
Instead of every key expiring at exactly 3600 seconds:
3600 Β± random jitter
This spreads refresh work.
Negative caching
An application can cache "not found" briefly.
Example:
user:999 -> missing
TTL 30 sec
Useful when bots repeatedly request nonexistent IDs.
Use short TTLs so newly created data does not remain hidden too long.
Hot keys
A hot key receives disproportionate traffic.
Example:
global:counter
all requests hit one location.
Distributed storage cannot automatically parallelize an operation that truly serializes through one key.
Possible designs:
- sharded counters,
- local caches,
- replicated reads,
- split state by tenant/time.
The semantics decide what is safe.
Large values
Bad:
one key -> 100 MB object
Costs:
- network latency,
- memory,
- replication,
- copy/deserialization,
- eviction impact.
Prefer bounded values.
Large collections
A set/sorted set with millions of members can become an operational hotspot.
Consider partitioning:
leaderboard:2026:region:eu
leaderboard:2026:region:us
if product semantics allow.
Atomic commands
Key-value systems often provide atomic server-side operations.
Example:
INCR inventory:reserved
If inventory:reserved starts at 10, the command returns 11 and stores 11:
| key | value before | result | value after |
|---|---|---|---|
inventory:reserved |
10 | 11 | 11 |
is safer than:
GET
application +1
SET
because two clients can race in the read-modify-write sequence.
Compare-and-set
Some systems support conditional writes:
update value only if version == 7
This is optimistic concurrency.
Use it for:
- locks,
- lease renewal,
- versioned state.
Distributed locks
A lock key can coordinate work:
lock:job:123
but distributed locks are easy to misuse.
A robust design needs:
- unique ownership token,
- expiration/lease,
- safe release,
- fencing/versioning for protected resources,
- behavior under network pauses.
Do not assume "SET if absent with TTL" solves every coordination problem.
Idempotency key
A useful key-value pattern:
idempotency:<request_id> -> result/status
On retry:
- client sends same request ID,
- server finds existing result,
- server avoids duplicate side effect.
Good for:
- payment requests,
- order submission,
- external webhook processing.
The key must be scoped to the right operation/customer.
Rate limiting
Fixed window:
rate:user:42:2026-10-01T15 -> count
Increment atomically.
Expire after the window.
Trade-off: traffic can burst around boundary.
Other algorithms:
- sliding window,
- token bucket,
- leaky bucket.
Choose from fairness and cost requirements.
Session storage
Key:
session:<random-token>
Value:
{
"user_id": 42,
"roles": ["customer"],
"created_at": "...",
"csrf_state": "..."
}
Security considerations:
- token must be unpredictable,
- TTL should match session policy,
- sensitive values should be minimized,
- logout should invalidate/revoke state.
Write-through cache
Application writes through the cache layer to durable storage.
Pros:
- cache stays populated,
- consistent write path.
Cons:
- cache becomes part of critical writes,
- failure handling becomes more complex.
Write-behind
Cache accepts writes, durable database updates later.
This can reduce apparent latency.
It increases risk:
- data loss before flush,
- ordering problems,
- recovery complexity.
Use only when business semantics tolerate it.
Cache versus source of truth
Ask explicitly:
If every cache key disappears now,
can the system reconstruct correct state?
If yes, it is likely a cache/derived store.
If no, you are using it as primary storage and need a durability/recovery design.
Persistence
Some key-value stores can persist memory state.
Persistence options may include:
- snapshots,
- append-only log,
- replicas.
Persistence changes recovery properties but not automatically business correctness.
See the Redis engine note for concrete details.
Eviction
A cache with bounded memory must choose what happens under pressure.
Potential outcomes:
- evict old/less-used keys,
- evict only expiring keys,
- reject writes.
The application should know what policy applies.
Key cardinality
Track:
- total keys,
- keys per namespace,
- memory per namespace,
- TTL distribution.
A memory leak can appear as:
session:* key count grows forever
because expiration was forgotten.
Scan versus lookup
Direct lookup:
GET session:abc
Input key-value row:
| key | value |
|---|---|
session:abc |
{"user_id":42,"role":"reader"} |
Result:
| key | returned value |
|---|---|
session:abc |
{"user_id":42,"role":"reader"} |
matches the model.
Scanning every key to find matching values does not.
If the application frequently scans:
all keys where country = DE
add a maintained index/set or use a database designed for that query.
Secondary lookup structures
You can maintain extra sets:
users_by_country:DE -> {42, 87, 101}
But now writes must update:
- user value,
- country index.
This is application-managed denormalization.
Define transaction/atomicity behavior.
Sharding
Distributed key-value systems hash or range-partition keys.
Key design affects:
- load distribution,
- multi-key operations,
- locality.
Redis Cluster, for example, uses hash slots.
Related keys can be forced into one slot with hash tags where appropriate.
See the Redis engine note for the concrete behavior.
Multi-key operations
A simple key-value model becomes harder when one operation touches many keys.
Example:
cart:<id>
inventory:<sku>
coupon:<code>
customer:<id>
Ask whether the database supports:
- atomic multi-key transactions,
- same-shard requirement,
- optimistic checks.
Do not assume single-key atomicity extends globally.
Serialization format
Opaque values need a format:
- JSON,
- MessagePack,
- Protobuf,
- custom binary.
Consider:
- backward compatibility,
- forward compatibility,
- size,
- debugging,
- language interoperability.
Schema versioning
Serialized value:
{
"version": 2,
"user_id": 42,
"items": []
}
The application can migrate older values when read.
A cache can often simply invalidate old-version keys instead.
Versioned namespace
Another pattern:
profile:v3:user:42
Deploy new code to use v3.
Old keys expire naturally.
This is simple for derived caches.
Metrics
Monitor:
- hit ratio,
- miss ratio,
- evictions,
- memory,
- key count,
- command latency,
- hot keys,
- expired keys,
- replication lag,
- rejected connections.
A high hit ratio does not prove value if misses are extremely expensive.
Runnable Redis demo
The engine chapter already includes:
This NoSQL chapter adds a key-design exercise:
scripts/redis/nosql_key_design.redis
It demonstrates:
- namespace conventions,
- TTL,
- atomic counters,
- sorted-set ranking,
- idempotency keys.
Common mistakes
Key does not include all result dimensions
Wrong cached response returned.
No TTL on disposable data
Memory grows forever.
One global hot key
Throughput serializes.
GET-modify-SET for counters
Lost updates occur.
Treating cache as durable truth accidentally
Eviction becomes data loss.
Scanning keyspace for normal queries
Access model is wrong.
Distributed lock without lease/fencing design
Stale owner can corrupt protected resource.
Modeling checklist
- What exact key does each request know?
- Is value size bounded?
- Is this cache or authoritative state?
- What TTL should apply?
- How is stale data invalidated?
- Can a key become hot?
- Are updates expressible atomically?
- Do operations span multiple keys?
- What happens after eviction?
- How are retries/idempotency handled?
- What serialization/versioning is needed?
- Which metrics prove the store is healthy?