KAHIBARO
Discord Login Register

16.3. Redis

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:

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:

TypeExample valueTypical caching use
String"user:42" -> JSON stringStore 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
ZSetscore -> memberRankings, recent items, leaderboards

For a first caching implementation, strings and hashes are often enough.

Example: cache user data as a string:

text
SET user:42 '{"id":42,"name":"Alice"}'
EXPIRE user:42 600      # expire in 600 seconds

Example: cache user data as a hash:

text
HSET user:42 name "Alice" age "30"
EXPIRE user:42 600

Basic Redis Commands for Caching

You mainly use a small set of commands for caching.

Reading and writing simple values

Example sequence:

text
SET page:/home "<html>...</html>"
GET page:/home
DEL page:/home

You can add expiration when writing:

Example:

text
SETEX user:profile:42 300 '{"id":42,"name":"Alice"}'

This key will be removed by Redis after 300 seconds.

Checking existence

Example:

text
EXISTS user:profile:42  # returns 1 if exists, 0 if not

This is useful when deciding whether to hit the database or not.

Hash operations

For more structured data:

Example:

text
HSET user:42 name "Alice"
HSET user:42 age "30"
HGET user:42 name        # "Alice"
HGETALL user:42          # all fields

Then add an expiration:

text
EXPIRE user:42 600

Designing Cache Keys

Cache keys must be:

Common patterns

Use namespaces separated by colons:

PurposeKey pattern
User datauser:{user_id}
Product dataproduct:{product_id}
Page HTMLpage:{path}
Search resultssearch:{query}:{page}
API responsesapi:{version}:{resource}:{id}
Session datasession:{session_id}
Rate limiting countersrate:{user_id}:{endpoint}:{time_slot}

Example for a product:

text
product:123
product:123:price
product:123:details

You can build these keys in code:

python
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:

text
GET /products?sort=price_asc&page=2

You might use:

text
products:list:sort=price_asc:page=2

You can serialize parameter dictionaries in a deterministic way:

python
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:

python
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:

Setting expiration

You can set expiration in several ways:

text
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 milliseconds

Typical expiration choices:

Data typeExample TTL (seconds)
User profile300 to 3600
Product catalog60 to 600
Home page HTML30 to 300
Session data1800 to 86400
Rate limit counters60 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:

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:

Cache-aside Pattern

The most common way to use Redis for caching is the cache‑aside pattern.

Basic idea:

  1. When you need data, first check the cache.
  2. If it exists, return it (cache hit).
  3. If it does not exist, load it from the database, store it in cache, and then return it (cache miss).

Pseudocode:

text
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 data

This 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:

python
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 user

Benefits and trade‑offs

Benefits:

Trade‑offs:

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:

Example:

python
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:

  1. Update database
  2. Write new value into Redis

Example:

python
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 user

Pro:

Con:

3. Cache invalidation on write (delete keys)

On update, you remove the cache entry. Next read will repopulate it.

Sequence:

  1. Update database
  2. Delete relevant cache key

Example:

python
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 user

This 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:

For example, when a product price changes, you may want to:

python
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:

Example with versions:

python
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:

python
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:

python
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:

Example cached value:

json
{
  "data": {...},
  "fetched_at": 1710000000
}

Then:

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:

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:

You can write reusable helpers.

Simple query cache helper

python
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 data

Usage:

python
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

python
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:

python
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 user

When the user changes, you can:

python
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 user

This 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:

Example:

python
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 user

Pitfall 2: Caching everything

Not all data should be cached. Cache:

Do not cache:

Pitfall 3: Storing very large values

If you store huge JSON blobs in Redis:

Better options:

Pitfall 4: Inconsistent serialization

Always serialize and deserialize consistently. Common approach:

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:

  1. Design keys that clearly represent what is cached
  2. Implement cache‑aside helpers for common patterns
  3. Set TTLs appropriate to each data type and add jitter
  4. Handle cache invalidation on updates or with short TTLs
  5. Protect the database by preventing cache stampedes
  6. 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

Comments

Please login to add a comment.

Don't have an account? Register now!