Skip to main content

Key-Value Stores

Sub-millisecond access with simple get/set semantics and massive scale

TL;DR

Key-value stores like Redis and DynamoDB provide ultra-fast access with simple get/set/delete operations. Perfect for caching, sessions, real-time leaderboards, and high-throughput scenarios where you don't need complex queries. Trade-off: no complex queries, eventual consistency, typically in-memory (limited by RAM).

Learning Objectives

  • Understand when key-value access patterns apply
  • Design efficient cache invalidation strategies
  • Recognize distributed KV patterns (sharding, replication)
  • Choose between Redis and DynamoDB for your use case

Motivating Scenario

Your e-commerce site has 100K concurrent users browsing. Product inventory changes frequently. Reading from PostgreSQL causes 100ms latency. Redis caches hot product data, dropping latency to <1ms. Users see inventory updates within seconds through cache invalidation.

Core Concepts

Data Structures

Modern key-value stores support rich data types:

Strings:     get "user:123:name" -> "Alice"
Hashes: hget "user:123" "email" -> "alice@example.com"
Lists: lpush "notifications:456" "New message"
Sets: sadd "users:online" "user:123"
Sorted Sets: zadd "leaderboard" 1000 "user:123"
Streams: Event log with consumer groups
Bitmaps: Daily active users tracking
HyperLogLog: Cardinality estimation

Consistency Models

Redis (In-Memory)
  1. Sub-millisecond latency required
  2. Session storage, caching
  3. Real-time leaderboards
  4. Pub/Sub messaging
  5. Data loss acceptable (cache)
DynamoDB (Managed)
  1. Serverless, fully managed
  2. AWS ecosystem integration
  3. Automatic scaling
  4. Global distribution
  5. Durability guaranteed

Practical Example

import redis
from datetime import timedelta
import json

# Connect to Redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# Simple caching pattern
def get_user(user_id):
cache_key = f"user:{user_id}"

# Check cache
cached = r.get(cache_key)
if cached:
return json.loads(cached)

# Cache miss - fetch from database
user = fetch_from_database(user_id)

# Store in cache with 1 hour TTL
r.setex(cache_key, timedelta(hours=1), json.dumps(user))
return user

# Leaderboard using sorted sets
def add_score(user_id, score):
r.zadd("leaderboard", {f"user:{user_id}": score})

def get_top_10():
# Get top 10 by descending score
return r.zrevrange("leaderboard", 0, 9, withscores=True)

# Session storage
def store_session(session_id, user_data, ttl_hours=24):
r.hset(f"session:{session_id}", mapping=user_data)
r.expire(f"session:{session_id}", timedelta(hours=ttl_hours))

# Pub/Sub for real-time notifications
def publish_notification(user_id, message):
r.publish(f"notifications:{user_id}", message)

def subscribe_to_notifications(user_id, callback):
pubsub = r.pubsub()
pubsub.subscribe(f"notifications:{user_id}")
for message in pubsub.listen():
if message['type'] == 'message':
callback(message['data'])

When to Use Key-Value Stores / When Not to Use

Use Key-Value Stores When
  1. Sub-millisecond latency required
  2. Simple get/set/delete access patterns
  3. Session storage, caching needed
  4. Real-time leaderboards, rankings
  5. Rate limiting, throttling
Use RDBMS When
  1. Complex queries with filtering
  2. Multi-table joins needed
  3. Transactional consistency required
  4. Data must be durable by default
  5. Complex reporting

Patterns and Pitfalls

Design Review Checklist

  • Access pattern is simple key-based lookup
  • Cache invalidation strategy defined
  • TTL/expiration times appropriate
  • Replication/HA configured (Master-Slave or Sentinel)
  • Memory capacity planned for data growth
  • Eviction policy chosen appropriately
  • Monitoring for hit rate, evictions, memory
  • Backup/persistence strategy documented
  • Connection pooling configured
  • Partition key design prevents hotspots

Self-Check

  • What's the difference between Redis and DynamoDB?
  • How do you prevent cache stampede?
  • Why should you use hashes in Redis instead of separate string keys?
  • What consistency guarantees does your key-value store provide?
info

Key-value stores excel at simple lookups and caching, trading away complex queries for sub-millisecond latency. Use them as a caching layer in front of RDBMS, never as your primary data store for critical data.

Advanced Key-Value Patterns

Cache Invalidation Strategies

# Cache-Aside (most common): App checks cache, on miss loads from DB
def get_user(user_id):
cache_key = f"user:{user_id}"

# Check cache first
user = cache.get(cache_key)
if user:
return user

# Cache miss: load from database
user = database.get_user(user_id)

# Store in cache with TTL
cache.setex(cache_key, 3600, json.dumps(user))
return user

# Write-Through: Update cache and DB together
def update_user(user_id, updates):
cache_key = f"user:{user_id}"

# Update database first
user = database.update_user(user_id, updates)

# Update cache (if update succeeded)
cache.setex(cache_key, 3600, json.dumps(user))
return user

# Write-Behind (write-back): Write to cache, async to DB
def update_user_async(user_id, updates):
cache_key = f"user:{user_id}"

# Update cache immediately (fast response)
cache.setex(cache_key, 3600, json.dumps(updates))

# Queue async write to database
async_queue.put(('update_user', user_id, updates))

return {'status': 'pending'}

# Pros/Cons:
# Cache-Aside: Simple, but risk of stale reads
# Write-Through: Always consistent, slower writes
# Write-Behind: Fast, but risk of data loss if cache crashes

Distributed Caching with Redis Cluster

# Redis Cluster handles sharding and replication
import redis

# Single node (for development)
r_single = redis.Redis(host='localhost', port=6379)

# Redis Cluster (for production)
r_cluster = redis.RedisCluster(
startup_nodes=[
{"host": "node1", "port": 6379},
{"host": "node2", "port": 6379},
{"host": "node3", "port": 6379},
],
decode_responses=True,
skip_full_coverage_check=True
)

# Consistent hashing automatically distributes keys
# Same key always goes to same node
for user_id in range(1000000):
key = f"user:{user_id}"
r_cluster.set(key, json.dumps({'id': user_id}))

# Rebalancing happens automatically when nodes added/removed
# Data is automatically replicated to replica nodes
# If a node fails, replicas take over automatically

Rate Limiting with Redis

# Token bucket algorithm for rate limiting
class RateLimiter:
def __init__(self, redis_client, max_requests=100, window_seconds=60):
self.redis = redis_client
self.max_requests = max_requests
self.window_seconds = window_seconds

def is_allowed(self, user_id: str) -> bool:
key = f"rate_limit:{user_id}"

# Increment counter
current = self.redis.incr(key)

# Set expiration on first request in window
if current == 1:
self.redis.expire(key, self.window_seconds)

# Check if over limit
return current <= self.max_requests

def get_remaining(self, user_id: str) -> int:
key = f"rate_limit:{user_id}"
current = self.redis.get(key) or 0
return max(0, self.max_requests - int(current))

# Usage in middleware
def rate_limit_middleware(request):
limiter = RateLimiter(redis_client)
user_id = request.user.id

if not limiter.is_allowed(user_id):
remaining = limiter.get_remaining(user_id)
return Response(
status=429,
headers={
'Retry-After': str(limiter.window_seconds),
'X-RateLimit-Remaining': str(remaining)
}
)
return request

Distributed Locks with Redis

# Mutex pattern for critical sections
import uuid
import time

class DistributedLock:
def __init__(self, redis_client, resource: str, timeout_seconds=30):
self.redis = redis_client
self.resource = resource
self.timeout = timeout_seconds
self.lock_id = str(uuid.uuid4())

def acquire(self, blocking=True) -> bool:
key = f"lock:{self.resource}"

# Try to set key (NX = only if not exists)
acquired = self.redis.set(
key,
self.lock_id,
ex=self.timeout, # Expiration prevents deadlock
nx=True # Only set if doesn't exist
)

if acquired or not blocking:
return bool(acquired)

# Wait for lock (blocking)
deadline = time.time() + 10
while time.time() < deadline:
acquired = self.redis.set(key, self.lock_id, ex=self.timeout, nx=True)
if acquired:
return True
time.sleep(0.1)

return False

def release(self):
key = f"lock:{self.resource}"

# Only release if we own it (prevent releasing others' locks)
current = self.redis.get(key)
if current == self.lock_id:
self.redis.delete(key)

def __enter__(self):
self.acquire()
return self

def __exit__(self, *args):
self.release()

# Usage
with DistributedLock(redis, "checkout_inventory") as lock:
# Critical section: only one process can execute this at a time
inventory = db.get_inventory(product_id)
if inventory > 0:
db.update_inventory(product_id, inventory - 1)

Comparison: When to Use Each

Use Redis When:
- Sub-millisecond latency required
- Bounded dataset (fits in memory)
- Session storage
- Real-time leaderboards
- Pub/Sub messaging
- Rate limiting

Use DynamoDB When:
- Serverless/managed preference
- Unbounded data size
- AWS ecosystem integration
- Multi-region replication needed
- Compliance requirements (fully audited)
- Pay-per-request pricing works for workload

Use Memcached When:
- Simple key-value (no data structures)
- Horizontal scaling is priority
- Extremely high throughput needed
- Simpler operations than Redis

Use Application Memory When:
- Single-instance application
- Data fits in process memory
- No need for distributed access
- Testing/development only

Next Steps

  • Explore Caching Patterns for strategies like cache-aside, write-through
  • Learn In-Memory Caches and Data Grids for Memcached and Hazelcast
  • Study Read Replicas for scaling beyond single instance
  • Dive into Sharding Strategies for partitioning across nodes
  • Implement Cache Warming for predictable workloads
  • Design Eviction Policies for memory-constrained environments

References

  • Redis Official Documentation (redis.io)
  • "Designing Data-Intensive Applications" by Martin Kleppmann
  • AWS DynamoDB Design Patterns (docs.aws.amazon.com)
  • "The Art of Multiprocessor Programming" by Herlihy & Shavit
  • Redis Cluster Tutorial: https://redis.io/topics/cluster-tutorial