16.7 Distributed Caching
Table of Contents
Why Distributed Caching?
When your application runs on a single server, a simple in‑memory cache (for example a Python dict or an in‑process LRU cache) is often enough.
When you scale your backend horizontally, and run many instances behind a load balancer, this no longer works well:
- Each instance has its own cache
- Different instances may have different data for the same key
- Cache hit rate drops, since data is scattered
- Cache invalidation becomes very hard
A distributed cache solves this by providing a single logical cache that is shared by multiple application instances, often on multiple servers.
Common distributed cache tools:
| Tool | Type | Typical Use |
|---|---|---|
| Redis | In‑memory store | General caching, sessions, rate limiting |
| Memcached | Cache only | Simple key value caching |
| Hazelcast | In‑memory grid | JVM apps, distributed data structures |
Distributed caching is almost always built on top of a networked, shared cache server or cluster, not on local memory of each app server.
Important: A distributed cache is not your source of truth. The database or another persistent store remains the system of record, and the cache is a performance optimization only.
Basic Architecture of a Distributed Cache
Single Cache Server with Multiple App Instances
The simplest setup:
- Several backend instances.
- One shared cache server (for example Redis).
- All instances read and write from this cache.
ASCII diagram:
+-------------+
| Load |
| Balancer |
+------+------+
|
+-----------+-----------+
| | |
+----+----+ +---+----+ +---+----+
| App A | | App B | | App C |
+----+----+ +---+----+ +---+----+
\ | /
\ | /
\ | /
+-------+--------+
| Redis / Cache |
+----------------+Each app uses the same code to interact with the cache:
- On read: Try cache first, fall back to database if missing.
- On write: Update database, then update or invalidate cache.
Cache Cluster and Sharding
As load grows, a single cache server may not be enough. Two scaling patterns appear:
- Replication
Multiple nodes, each has (roughly) the same data. Focus on availability and read scalability. - Sharding
Data is split across nodes. Each node holds only a part of the key space.
With sharding, you need to decide which node holds a given key. This is often done with consistent hashing (described later).
Using a Distributed Cache in Your Backend
Typical Cache Access Pattern
The core pattern looks like this:
- Read flow
- App receives request.
- App computes a cache key, for example
"user:123". - Check cache:
- If found, return cached value.
- If not found, read from database, then write to cache, then return.
- Write flow
- App writes change to database (source of truth).
- App invalidates related cache keys or updates them.
In pseudocode:
def get_user(user_id: int) -> User:
key = f"user:{user_id}"
cached = cache.get(key)
if cached is not None:
return cached
user = db.get_user(user_id)
if user is not None:
cache.set(key, user, ttl=600) # 10 minutes
return user
def update_user(user_id: int, data: dict) -> User:
user = db.update_user(user_id, data)
cache.delete(f"user:{user_id}") # or cache.set(...) with new data
return userThis pattern by itself is similar to local caching. The "distributed" part is simply that all app instances use the same cache server, so they all see the same cache state.
Consistency and Cache Invalidation
Distributed caching adds new consistency problems. Multiple app servers may:
- Update the database.
- Update or invalidate the cache.
- Receive stale data from cache while another server has just updated the database.
There is no completely perfect pattern, but there are standard strategies.
Cache Aside (Lazy Loading)
This is the most common pattern.
- Application code explicitly manages cache entries.
- Cache is populated on demand.
Read:
- Try cache.
- On miss, query database, then write to cache.
Write:
- Write to database.
- Invalidate or update cache entry.
Pros:
- Simple to implement.
- Database always the primary source of truth.
Cons:
- Short period of stale reads may occur between DB update and cache invalidation.
- First request after expiration is slower, since it hits the database.
Write Through
With write through, the cache is treated more like the primary interface:
- Application writes to the cache.
- Cache layer synchronously writes to database.
Write:
- Write to cache.
- Cache writes to database before acknowledging success.
Read:
- Same as cache aside: read from cache, fallback to database only in unusual cases.
Pros:
- Cache always up to date when writes succeed.
- Fewer inconsistencies between DB and cache.
Cons:
- Writes are slower, because every write touches both cache and DB.
- Implementation is more complex, often inside a library or middleware.
Write Behind (Write Back)
With write behind, writes go to the cache first, and the cache writes to the database asynchronously in the background.
Pros:
- Very fast writes for clients.
- Good when you can tolerate some delay before data becomes durable.
Cons:
- Complex and riskier:
- Data can be lost if cache fails before writing to DB.
- Consistency is more complicated.
For most web backends, cache aside is usually the best starting point.
Invalidation Strategies
The hardest part in cache design is invalidation.
Common patterns:
- Time‑based expiration (TTL)
Each key has a time to live. After TTL, the key is removed or treated as expired. - Key‑based invalidation
When something changes, you explicitly delete or update affected keys. - Pattern‑based invalidation
Some caches support deletion by pattern, for exampleuser:*. This can be expensive at scale. - Versioned keys
Part of the key includes a version or timestamp. When data changes, you bump the version, and all new reads use the new key. Old keys eventually expire.
Example of versioned key:
"user:v3:123"
If user data schema or meaning changes, you switch from v2 to v3. Old entries become unused and are cleaned up by expiration.
Designing Cache Keys in a Distributed Cache
Good key design is critical, especially when many services share the same cache.
Namespacing
Use prefixes to keep keys organized:
| Type of data | Example key |
|---|---|
| User object | user:123 |
| User permissions | user:123:permissions |
| Product details | product:987 |
| Category products list | category:42:products:page:1 |
| Rate limit | ratelimit:/api/login:ip:1.2.3.4 |
Rules of thumb:
- Use a stable prefix for each logical data type.
- Include identifiers (user id, product id, etc).
- For lists or queries, embed relevant parameters in the key:
products:category:42:sort:price_asc:page:2.
Rule: A cache key must uniquely represent what is cached. If two different queries use the same key, you will return incorrect data.
Key Length and Memory
Distributed caches often handle many keys. You need a balance:
- Keys that are too short are confusing.
- Keys that are too long waste memory.
A typical reasonable length is 20 to 100 characters, depending on your use case.
Replication, Sharding, and Consistent Hashing
As your distributed cache grows, a single node will not be enough.
Replication
With replication, the same data is stored on multiple nodes.
Two typical goals:
- High availability
If one node fails, others can serve data. - Read scaling
Clients can read from replicas, writing only to the master.
Many Redis setups use:
- One primary (or master) node.
- Several replica nodes which copy data from the primary.
Writes go to primary, reads can be balanced over replicas, with some delay.
This provides eventual consistency: replicas may lag slightly behind primary.
Sharding
With sharding, the key space is split between nodes. Each key belongs to one primary node.
Example with 3 nodes:
- Keys for user ids 1 to 10,000 on node A.
- 10,001 to 20,000 on node B.
- 20,001 to 30,000 on node C.
In practice, sharding is usually based on a hash of the key, not directly on its numeric portion.
Consistent Hashing (Conceptual View)
Consistent hashing is a technique to decide which node stores a key while minimizing the impact of adding or removing nodes.
Idea:
- Map each cache node to a point on a circle based on a hash of its name, for example
hash("nodeA"). - Map each key to a point on the same circle, for example
hash("user:123"). - The key is assigned to the first node clockwise from its point on the circle.
- When you add or remove a node, only a fraction of keys move to new nodes.
You rarely need to implement this yourself. Many clients and systems provide this logic.
What matters for you as a backend developer:
- When you scale out or in, some keys will move, which can cause cold cache for those keys.
- Systems with consistent hashing reduce this effect.
Dealing with Stale or Incorrect Data
Distributed caches often trade strong consistency for performance and availability. You will occasionally serve stale data.
You need to understand and control:
- How stale data can be.
- How long it can remain stale.
Staleness Due to Replication Lag
With primary and replica nodes:
- App writes data to primary.
- Replication to replicas happens asynchronously.
- Another app instance reads from a replica before replication happens.
Solution options:
- For critical reads just after a write, force reads to go to the primary.
- Use read‑your‑writes patterns in your app logic for sensitive flows, for example after a user updates a profile, the same user sees data from the main store, not the cache.
Staleness Due to TTL
If you use TTL, keys can stay in cache after the underlying data changed.
Mitigations:
- Shorter TTL for frequently updated data.
- Explicit deletions on write, in addition to TTL.
- For rarely changing data, use longer TTL to improve performance.
Race Conditions in Invalidation
Classic problem:
- Request A reads from database and writes new value to cache.
- Request B writes to database and invalidates cache.
- Request A writes stale value to cache, after B updated DB.
Result: stale data in cache.
Typical mitigation:
- Always update database first, then update cache with the new value, not delete it, when possible.
- Use a version field or timestamp in the value and ignore stale values if you detect them.
- In critical code, use some form of lock around updating a particular key.
Coordination and Locks in a Distributed Cache
Sometimes you need coordination across nodes, for example:
- Only one instance should perform a particular job at a time.
- Only one instance should rebuild a heavy cache entry at a time.
Distributed caches like Redis can be used as a distributed lock service.
Simple Lock Pattern
You can use a key as a lock:
- Try to set key
lock:report:2024-01withNX(only if not exists) and a short TTL. - If set succeeds, you own the lock and can do the work.
- After work, delete the lock key.
- If set fails, someone else owns the lock, so you skip or wait.
Pseudocode:
lock_key = "lock:report:2024-01"
got_lock = cache.set(lock_key, "1", nx=True, ex=60) # 60s TTL
if got_lock:
try:
generate_report()
finally:
cache.delete(lock_key)
else:
# another worker is already doing this
passThis pattern uses the cache as a coordination mechanism across multiple backend processes or servers.
Using Distributed Caching in a Multi‑Service System
In a microservices or modular architecture, different services may use the same Redis cluster or separate ones.
Shared Cache Cluster
Multiple services using one cache cluster:
Pros:
- Easier to manage a single cluster.
- You can share some data between services if needed.
Cons:
- Keys from different services can conflict if not carefully named.
- Noisy neighbor effect: one service can consume most memory or operations.
To make this workable:
- Use service prefixes, for example
user-service:user:123. - Use separate Redis databases or logical partitions when possible.
Per‑Service Cache
Each service has its own cache instance or cluster.
Pros:
- Isolation between services.
- Easier capacity planning per service.
Cons:
- More infrastructure to manage.
- Shared data must be duplicated or read from source.
Both approaches are used in real systems. For a beginner, using one shared Redis with clear key namespaces is a good start.
When to Use Distributed Caching and When Not To
Distributed caching is powerful but introduces extra moving parts and possible bugs.
Good Use Cases
- Heavy read load against relatively stable data:
- Product catalogs.
- User profiles.
- Configuration values.
- Expensive computations:
- Aggregated statistics.
- Rendered templates or fragments.
- Rate limiting and quotas:
- Distributed rate limiters across many app instances.
- Session storage in stateless application servers.
Less Suitable Use Cases
- Highly volatile data that changes very often, where writes dominate reads.
- Data that must always be strongly consistent with the database.
- Very small projects with a single server, where local in‑memory caching is simpler.
Guideline: If you cannot clearly define:
- What data to cache,
- How long it may be stale,
- How it will be invalidated,
then you are not ready to add a distributed cache for that data yet.
Practical Example: Caching Product Details with Redis
Consider an e‑commerce backend with multiple API servers.
Cache Aside for Product Details
User requests product details:
- App receives
GET /products/123. - App checks Redis with key
product:123. - If cache hit, return data.
- If cache miss:
- Read product from PostgreSQL.
- Write to Redis with TTL 300 seconds.
- Return response.
On admin update:
- Admin calls
PUT /admin/products/123. - Service updates product in PostgreSQL.
- Service deletes Redis key
product:123or writes updated product to cache.
This works across all app servers because:
- All servers talk to the same Redis.
- Any server can populate cache.
- Any server can invalidate cache.
Caching Product Lists
Lists are trickier. For example, products in category 10, sorted by price, page 2:
Key could be:
"products:category:10:sort:price_asc:page:2"When a product in category 10 changes price:
- It might move between pages.
- It might change sorting order.
You have choices:
- Aggressive invalidation
Invalidate all pages for that category:
products:category:10:*This can be expensive since pattern deletes may scan many keys.
- Short TTL
Use a short TTL, for example 60 seconds, and do not manually invalidate. - Hybrid
Keep TTL but still try to delete the most obviously affected keys, for example first page or a few pages.
In practice, you often start with short TTLs for complex lists and optimize later if needed.
Summary
You have seen how distributed caching extends simple caching to multiple servers:
- Shared cache like Redis or Memcached acts as a central store.
- Common patterns:
- Cache aside for most CRUD operations.
- Replication and sharding for scaling.
- Key design, invalidation, and handling staleness are the main challenges.
- Distributed locking and coordination can be built on top of the cache.
- Not every piece of data should be cached. Choose data that is read often and can tolerate controlled staleness.
With this understanding, you can recognize where a distributed cache fits into your backend design and how to use it without breaking correctness.
Views: 8
KAHIBARO