16.3. Redis
Table of Contents
Why Redis Is Useful for Backend Developers
Redis is an in‑memory data store. This means it keeps data in RAM instead of on disk, so it is very fast. Backend developers use Redis mainly for:
- Caching expensive operations and database queries
- Storing user sessions
- Implementing rate limiting
- Handling background job queues
- Storing simple real‑time data like counters
You will not use Redis as your main business database in most projects. Instead, you use it next to a relational database like PostgreSQL to make your application faster and more scalable.
Key idea: Redis is usually a secondary store used for speed, not your system of record.
In this chapter you will focus on how to use Redis specifically for caching in backend applications.
Redis Data Types Relevant to Caching
Redis supports many data types. For caching you mainly use:
| Type | Example value | Typical caching use |
|---|---|---|
| String | "user:42" -> JSON string | Store a whole object or API response |
| Hash | "user:42" -> {name, ...} | Store fields of an object, update parts only |
| List | ["item1","item2"] | Store ordered logs, feeds, or queues |
| Set | {"tag1","tag2"} | Store unique sets, membership checks |
| ZSet | score -> member | Rankings, recent items, leaderboards |
For a first caching implementation, strings and hashes are often enough.
Example: cache user data as a string:
SET user:42 '{"id":42,"name":"Alice"}'
EXPIRE user:42 600 # expire in 600 secondsExample: cache user data as a hash:
HSET user:42 name "Alice" age "30"
EXPIRE user:42 600Basic Redis Commands for Caching
You mainly use a small set of commands for caching.
Reading and writing simple values
SET key valueGET keyDEL key
Example sequence:
SET page:/home "<html>...</html>"
GET page:/home
DEL page:/homeYou can add expiration when writing:
SETEX key seconds valueEXPIRE key seconds
Example:
SETEX user:profile:42 300 '{"id":42,"name":"Alice"}'This key will be removed by Redis after 300 seconds.
Checking existence
EXISTS key
Example:
EXISTS user:profile:42 # returns 1 if exists, 0 if notThis is useful when deciding whether to hit the database or not.
Hash operations
For more structured data:
HSET key field valueHGET key fieldHGETALL keyHDEL key field
Example:
HSET user:42 name "Alice"
HSET user:42 age "30"
HGET user:42 name # "Alice"
HGETALL user:42 # all fieldsThen add an expiration:
EXPIRE user:42 600Designing Cache Keys
Cache keys must be:
- Unique for each different piece of data
- Predictable so your code can rebuild them
- Stable so you do not get unexpected collisions
Common patterns
Use namespaces separated by colons:
| Purpose | Key pattern |
|---|---|
| User data | user:{user_id} |
| Product data | product:{product_id} |
| Page HTML | page:{path} |
| Search results | search:{query}:{page} |
| API responses | api:{version}:{resource}:{id} |
| Session data | session:{session_id} |
| Rate limiting counters | rate:{user_id}:{endpoint}:{time_slot} |
Example for a product:
product:123
product:123:price
product:123:detailsYou can build these keys in code:
def product_key(product_id: int) -> str:
return f"product:{product_id}"Rule: Always centralize key generation in helper functions so you do not accidentally use different key formats in different places.
Including parameters in keys
If a response depends on parameters, you must include them in the key.
Example: list of products sorted and paginated:
GET /products?sort=price_asc&page=2You might use:
products:list:sort=price_asc:page=2You can serialize parameter dictionaries in a deterministic way:
def products_list_key(sort: str, page: int) -> str:
return f"products:list:sort={sort}:page={page}"If your query parameters are complex, you might hash them:
import hashlib
import json
def stable_hash(obj: dict) -> str:
data = json.dumps(obj, sort_keys=True)
return hashlib.sha256(data.encode()).hexdigest()[:16]
def search_results_key(params: dict) -> str:
return f"search:{stable_hash(params)}"Cache Expiration and Eviction
There are two related but different ideas:
- Expiration is about when you want the data to be considered invalid.
- Eviction is about what Redis removes when it runs out of memory.
Setting expiration
You can set expiration in several ways:
SETEX user:42 600 "..."
EXPIRE user:42 600
PEXPIRE user:42 1500 # milliseconds
TTL user:42 # time to live in seconds
PTTL user:42 # time to live in millisecondsTypical expiration choices:
| Data type | Example TTL (seconds) |
|---|---|
| User profile | 300 to 3600 |
| Product catalog | 60 to 600 |
| Home page HTML | 30 to 300 |
| Session data | 1800 to 86400 |
| Rate limit counters | 60 to 3600 |
Rule: Any cache entry should have a TTL unless you have a very clear reason not to.
Redis eviction policies (high level)
Redis keeps data in RAM. When memory is full, it must evict something. Redis has multiple eviction policies, such as:
noeviction(default)allkeys-lruvolatile-lruallkeys-random- and others
For caching, allkeys-lru or volatile-lru are common: Redis removes least recently used keys first.
You configure this at the server level, not in application code. In production you need to think about:
- Maximum memory allowed for Redis
- Which keys can be evicted
- Whether some data must never be evicted
Cache-aside Pattern
The most common way to use Redis for caching is the cache‑aside pattern.
Basic idea:
- When you need data, first check the cache.
- If it exists, return it (cache hit).
- If it does not exist, load it from the database, store it in cache, and then return it (cache miss).
Pseudocode:
function get_user(user_id):
key = "user:" + user_id
data = redis.GET(key)
if data exists:
return data
# cache miss
data = db.query_user(user_id)
redis.SETEX(key, 600, data)
return dataThis pattern is easy to understand and implement and gives you a lot of control. You usually start with this approach.
Example in Python with a database
Assume:
db_get_user(user_id)reads from PostgreSQLredis_clientis a Redis connection
import json
from typing import Optional, Dict
def user_cache_key(user_id: int) -> str:
return f"user:{user_id}"
def get_user(user_id: int) -> Optional[Dict]:
key = user_cache_key(user_id)
cached = redis_client.get(key)
if cached is not None:
# Redis returns bytes, decode and parse JSON
return json.loads(cached)
# Cache miss, query the database
user = db_get_user(user_id)
if user is None:
return None
# Store in cache for 10 minutes
redis_client.setex(key, 600, json.dumps(user))
return userBenefits and trade‑offs
Benefits:
- Simple logic
- Database is the source of truth
- Cache is only an optimization
Trade‑offs:
- After a write, cache may be out of date until you update it or it expires
- You must handle invalidation
Cache Invalidation Strategies
The hardest part of caching is invalidating old data correctly.
When underlying data changes, the cache must eventually reflect those changes. There are several strategies.
1. Expire only (time‑based)
You set a TTL and do not actively invalidate keys. You accept that for a short time, the cache might show old data.
Works well for:
- Data that does not change often
- Data that can be slightly stale, like product lists or home page sections
Example:
redis_client.setex(product_cache_key(product_id), 300, json.dumps(product))2. Write-through
On every write to the database, you also update the cache with the new value.
Sequence:
- Update database
- Write new value into Redis
Example:
def update_user(user_id: int, new_data: dict) -> dict:
user = db_update_user(user_id, new_data)
key = user_cache_key(user_id)
redis_client.setex(key, 600, json.dumps(user))
return userPro:
- Cache always has the latest value after writes
Con:
- Slightly more complex write path
- If the database update fails you must not update the cache
3. Cache invalidation on write (delete keys)
On update, you remove the cache entry. Next read will repopulate it.
Sequence:
- Update database
- Delete relevant cache key
Example:
def update_user(user_id: int, new_data: dict) -> dict:
user = db_update_user(user_id, new_data)
redis_client.delete(user_cache_key(user_id))
return userThis can be safer when you have many different keys derived from the same data, because you can delete all of them.
4. Invalidate related lists
Lists and aggregations often include multiple items. When one item changes, you might need to invalidate:
- Individual item cache
- Any cached list that might include that item
For example, when a product price changes, you may want to:
redis_client.delete(product_key(product_id))
redis_client.delete("products:list:sort=price_asc:page=1")
redis_client.delete("products:list:sort=price_desc:page=1")Instead of guessing all affected lists, another approach is to:
- Use short TTL for lists
- Or store a version number in Redis and include it in keys, then bump the version when anything changes
Example with versions:
def products_version_key() -> str:
return "products:version"
def get_products_version() -> int:
v = redis_client.get(products_version_key())
return int(v) if v is not None else 1
def bump_products_version() -> int:
# INCR returns the new value
return redis_client.incr(products_version_key())
def products_list_key(page: int) -> str:
version = get_products_version()
return f"products:list:v{version}:page={page}"When a product changes:
bump_products_version()Old keys will still exist but will not be used anymore, and they will expire naturally.
Key principle: It is often acceptable for caches to be eventually consistent. The database must remain the accurate source of truth.
Preventing Cache Stampede
A cache stampede happens when a popular cache key expires. Many requests suddenly miss the cache and all hit the database at once.
To reduce this:
1. Add jitter to TTL
Do not set the same expiration for every key. Add a random spread.
Example:
import random
def set_with_jitter(key: str, value: str, base_ttl: int = 600):
jitter = random.randint(-60, 60) # +/- 1 minute
ttl = max(60, base_ttl + jitter) # at least 60 seconds
redis_client.setex(key, ttl, value)Now keys expire at slightly different times instead of all at once.
2. Soft TTL vs hard TTL
You store:
- A hard TTL in Redis (key expiration)
- A soft TTL inside the value that tells you when to refresh
Example cached value:
{
"data": {...},
"fetched_at": 1710000000
}Then:
- If
now < fetched_at + soft_ttl, use data directly - If
now >= fetched_at + soft_ttlbut key still exists, serve data but trigger a background refresh
This way, users always get a response quickly, and refreshing happens in the background instead of many requests hitting the database at once.
3. Single-flight per key
A more advanced strategy is to ensure that for one key, only one request does the expensive work.
High level concept:
- When a request sees a cache miss, it tries to acquire a Redis lock for that key
- If it gets the lock, it loads data and writes to cache
- If it cannot get the lock, it waits a bit and then rechecks the cache
This reduces duplicate work and protects your database.
Caching Database Queries
A common pattern is to cache results of specific database queries.
Examples of queries to cache:
- “Get user by ID”
- “Get product by ID”
- “Get first 20 products ordered by created_at”
- “Get top 10 posts for home page”
You can write reusable helpers.
Simple query cache helper
import json
from typing import Callable, Any
def cache_result(key: str, ttl: int, loader: Callable[[], Any]) -> Any:
cached = redis_client.get(key)
if cached is not None:
return json.loads(cached)
data = loader()
redis_client.setex(key, ttl, json.dumps(data))
return dataUsage:
def get_product(product_id: int) -> dict:
key = f"product:{product_id}"
def loader():
return db_get_product(product_id)
return cache_result(key, ttl=300, loader=loader)Caching paginated lists
def products_page_key(page: int, per_page: int) -> str:
return f"products:page:{page}:per_page:{per_page}"
def get_products(page: int, per_page: int):
key = products_page_key(page, per_page)
def loader():
return db_get_products(page, per_page)
return cache_result(key, ttl=60, loader=loader)Here list pages are cached for 60 seconds.
Caching in Web APIs
In an API built with something like FastAPI, you can add caching around your endpoint logic.
Simplified example:
from fastapi import APIRouter, HTTPException
import json
router = APIRouter()
def user_api_cache_key(user_id: int) -> str:
return f"api:user:{user_id}"
@router.get("/users/{user_id}")
def read_user(user_id: int):
key = user_api_cache_key(user_id)
cached = redis_client.get(key)
if cached is not None:
return json.loads(cached)
user = db_get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
redis_client.setex(key, 300, json.dumps(user))
return userWhen the user changes, you can:
def update_user_endpoint(user_id: int, payload):
user = db_update_user(user_id, payload)
redis_client.delete(user_api_cache_key(user_id))
return userThis pattern is the same as the cache‑aside pattern, just directly around your HTTP handlers.
Common Pitfalls and Best Practices
Pitfall 1: Forgetting to handle failures
Redis is usually an optimization. Your system should still work if Redis is temporarily down.
Design principle:
- Fail open for caching. If Redis is unavailable, go directly to the database instead of returning errors.
Example:
def safe_get_user(user_id: int):
key = user_cache_key(user_id)
try:
cached = redis_client.get(key)
except Exception:
cached = None # ignore cache errors
if cached is not None:
return json.loads(cached)
# fall back to database
user = db_get_user(user_id)
if user is None:
return None
try:
redis_client.setex(key, 600, json.dumps(user))
except Exception:
pass # ignore cache write errors
return userPitfall 2: Caching everything
Not all data should be cached. Cache:
- Data that is expensive to compute or load
- Data that is read often and changes rarely
Do not cache:
- Very small, cheap queries
- Highly volatile data that changes on almost every request
Pitfall 3: Storing very large values
If you store huge JSON blobs in Redis:
- You waste memory
- Network transfer becomes slower
Better options:
- Store only what you need
- Split large values into smaller, logical parts
- Use hashes to update pieces instead of rewriting big strings
Pitfall 4: Inconsistent serialization
Always serialize and deserialize consistently. Common approach:
- Use JSON
- Always set encoding to UTF‑8
- Make sure date and time formats are handled in a predictable way
Consistency avoids bugs where different parts of the application interpret data differently.
Putting It All Together
A simple flow for using Redis as a cache in a backend application:
- Design keys that clearly represent what is cached
- Implement cache‑aside helpers for common patterns
- Set TTLs appropriate to each data type and add jitter
- Handle cache invalidation on updates or with short TTLs
- Protect the database by preventing cache stampedes
- Treat Redis as optional, not as your primary store
With these ideas and patterns, you can confidently introduce Redis caching into your backend projects and significantly improve performance while keeping your code simple and maintainable.
Views: 6
KAHIBARO