Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Sponsored Reserved space — layout preview until AdSense is connected
Python

Cache Consistency Problems and How Redis Helps Solve Them

Learn about common cache consistency issues like stale reads, cache stampedes, and write conflicts, and discover how Redis features such as atomic Lua scripts, pub/sub, and distributed locks can keep your data in sync.

July 2026 12 min read 1 views 0 hearts

You've probably been there. Your app is running smoothly, users are happy, and then suddenly someone reports seeing old data. A product that was marked "out of stock" still shows as available. A user's profile picture hasn't updated in hours. These are the classic signs of cache consistency problems, and they can make your application look unreliable.

Let's talk about what actually happens when caches go wrong, and how Redis can save you from these headaches.

The Real Problem with Caching

Here's the thing about caching: it's supposed to make things faster, but it often introduces a new kind of complexity. When you store data in two places—your database and your cache—you're essentially keeping two copies of the same information. And keeping those copies in sync is harder than it sounds.

Imagine you're running an e-commerce site on PythonSkillset. A customer updates their shipping address. Your database gets the new address, but your cache still holds the old one. Now when they check out, the system pulls the old address from cache. The package goes to the wrong place. That's a cache consistency problem in action.

Why Cache Consistency Is Tricky

The core issue is timing. When you update data in your database, you need to also update or invalidate the cached version. But these operations don't happen atomically. There's always a window where the cache and database disagree.

Consider this common scenario:

  1. User A updates their email address in the database
  2. Before the cache is updated, User B requests the same user's profile
  3. The cache still has the old email, so User B sees outdated information

This race condition is the root of most cache consistency headaches. And it gets worse when you have multiple application servers, each with their own cache, all trying to stay in sync.

The Three Main Consistency Problems

1. Stale Reads

This is the most common issue. Your database has fresh data, but your cache is serving old data. It happens when you update the database but forget to update the cache, or when the cache invalidation happens too slowly.

For example, at PythonSkillset, we once had a situation where article view counts were cached for 5 minutes. During a popular article launch, the view count showed 1,200 for nearly 10 minutes while the actual count was over 3,000. Readers were confused, and the author thought nobody was reading.

2. Cache Stampede

This one is nasty. When a cached item expires and multiple requests try to rebuild it simultaneously, they all hit your database at once. Your database gets overwhelmed, response times spike, and users see errors.

Think of it like everyone rushing to leave a concert at the same time through one exit. The system wasn't designed for that load, and things break.

3. Write Conflicts

When multiple services or users update the same data, you can end up with conflicting versions. One service updates the database but not the cache. Another service reads the cache and overwrites the database with old data. Before you know it, you've got a mess.

How Redis Tackles These Problems

Redis isn't just a fast key-value store. It has specific features designed to handle these consistency challenges. Let's look at what actually works.

1. Atomic Operations with Lua Scripting

Redis supports Lua scripts that run atomically. This means you can combine multiple operations into one unit that either completes fully or not at all.

Here's a practical example from PythonSkillset. When a user updates their profile, we need to update the database and invalidate the cache in one go:

import redis
import json

r = redis.Redis()

update_script = """
local cache_key = KEYS[1]
local db_key = KEYS[2]
local new_data = ARGV[1]

-- Update database (simulated here)
redis.call('SET', db_key, new_data)
-- Invalidate cache
redis.call('DEL', cache_key)

return "OK"
"""

r.eval(update_script, 2, 'user:123:cache', 'user:123:db', json.dumps(new_user_data))

This script runs as a single atomic operation. Either both the database and cache are updated, or neither is. No partial updates, no stale data.

2. Cache-Aside with Explicit Invalidation

The cache-aside pattern is simple: your application checks the cache first, and if there's a miss, it reads from the database and populates the cache. But the key is what happens on writes.

Instead of updating the cache when data changes, you delete the cached entry. The next read will miss the cache, fetch fresh data from the database, and repopulate the cache. This is called "lazy loading" or "cache-aside with invalidation."

def update_user_email(user_id, new_email):
    # Update database first
    database.update_user_email(user_id, new_email)
    # Then invalidate cache
    cache.delete(f'user:{user_id}')

def get_user(user_id):
    # Try cache first
    user = cache.get(f'user:{user_id}')
    if user:
        return user
    # Cache miss - get from database
    user = database.get_user(user_id)
    cache.set(f'user:{user_id}', user, ex=3600)
    return user

This pattern works well because you're not trying to keep the cache in sync with every write. You're simply removing the stale data and letting the next read fetch fresh data.

2. Write-Through with Redis

For data that changes frequently, you might want a write-through approach. Every write goes to both the database and Redis simultaneously. This keeps the cache always fresh, but it adds latency to every write operation.

Redis makes this practical because it's incredibly fast. A write to Redis takes microseconds, so the overhead is minimal. But you need to handle failures gracefully—if Redis is down, you shouldn't block the database write.

def update_user_preferences(user_id, preferences):
    try:
        # Update Redis first (fast)
        r.set(f'prefs:{user_id}', json.dumps(preferences))
        # Then update database
        database.update_preferences(user_id, preferences)
    except redis.ConnectionError:
        # Redis is down, just update database
        database.update_preferences(user_id, preferences)

2. Pub/Sub for Cache Invalidation

Redis has a built-in publish/subscribe system that's perfect for cache invalidation. When data changes, you publish a message saying "this key is stale." All your application servers subscribe to these messages and invalidate their local caches.

This is especially useful when you have multiple application servers. Instead of each server polling the database for changes, they get notified instantly.

import redis
import threading

r = redis.Redis()

def cache_invalidator():
    pubsub = r.pubsub()
    pubsub.subscribe('cache-invalidation')

    for message in pubsub.listen():
        if message['type'] == 'message':
            key = message['data'].decode()
            # Invalidate local cache
            local_cache.delete(key)
            print(f"Invalidated cache for {key}")

# Start listener in background thread
threading.Thread(target=cache_invalidator, daemon=True).start()

# When updating data
def update_article(article_id, content):
    database.update_article(article_id, content)
    r.publish('cache-invalidation', f'article:{article_id}')

This approach means all your application servers get notified instantly when data changes. No more stale reads across different servers.

3. Read-Through with Write-Behind

For high-write scenarios, you can use Redis as a write-behind cache. Writes go to Redis first, then asynchronously to the database. This gives you fast response times while ensuring eventual consistency.

import redis
import threading
import time

r = redis.Redis()

def write_behind_worker():
    while True:
        # Get pending writes from Redis list
        task = r.blpop('pending-writes', timeout=5)
        if task:
            key, data = task[1].split(':', 1)
            database.write(key, data)
            # Mark as synced
            r.set(f'synced:{key}', 'true')

# Start worker in background
threading.Thread(target=write_behind_worker, daemon=True).start()

def update_data(key, value):
    # Write to Redis immediately
    r.set(key, value)
    # Queue for database write
    r.rpush('pending-writes', f'{key}:{value}')

This pattern gives you fast writes while ensuring the database eventually catches up. The trade-off is that if Redis crashes before the worker processes the queue, you might lose some data. But for many applications, this is an acceptable risk.

4. TTL-Based Consistency

Sometimes the simplest solution is the best. Set a time-to-live (TTL) on your cached data. When the TTL expires, the cache entry is automatically removed, and the next read fetches fresh data.

The trick is choosing the right TTL. Too short, and you're defeating the purpose of caching. Too long, and users see stale data.

For PythonSkillset, we use different TTLs based on data volatility: - User session data: 30 minutes - Article content: 1 hour - Site configuration: 5 minutes - Trending topics: 2 minutes

This isn't perfect—there's still a window where data can be stale—but it's predictable and easy to reason about.

3. Redis Transactions for Complex Updates

When you need to update multiple related cache entries atomically, Redis transactions (MULTI/EXEC) are your friend. They ensure that either all operations succeed or none do.

def update_article_with_tags(article_id, content, tags):
    pipe = r.pipeline()
    pipe.multi()

    # Update article content
    pipe.set(f'article:{article_id}:content', content)
    # Update tag index
    pipe.delete(f'article:{article_id}:tags')
    for tag in tags:
        pipe.sadd(f'article:{article_id}:tags', tag)
        pipe.sadd(f'tag:{tag}:articles', article_id)

    pipe.execute()

This guarantees that when you update an article, both its content and tag associations are updated together. No partial updates, no orphaned tags.

4. Redis Streams for Eventual Consistency

For systems that can tolerate some delay, Redis Streams provide a reliable way to handle cache updates asynchronously. You publish change events to a stream, and consumer groups process them in order.

import redis
import json

r = redis.Redis()

def publish_change(entity_type, entity_id, action):
    event = {
        'type': entity_type,
        'id': entity_id,
        'action': action,
        'timestamp': time.time()
    }
    r.xadd('data-changes', event)

def process_changes():
    while True:
        # Read new events from stream
        events = r.xread({'data-changes': '0'}, count=10, block=5000)
        for stream, messages in events:
            for message_id, data in messages:
                if data['action'] == 'update':
                    # Invalidate cache
                    r.delete(f"{data['type']}:{data['id']}")
                elif data['action'] == 'delete':
                    r.delete(f"{data['type']}:{data['id']}")

This pattern gives you a reliable audit trail of all changes. If something goes wrong, you can replay events to rebuild your cache.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_with_lock(user_id, new_data):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            updated = merge_data(current, new_data)
            # Write to database
            database.update_user(user_id, updated)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

import redis
import time

r = redis.Redis()

def update_user_email(user_id, new_email):
    lock_key = f'lock:user:{user_id}'
    lock = r.lock(lock_key, timeout=10)

    if lock.acquire(blocking=True):
        try:
            # Read current data
            current = database.get_user(user_id)
            # Apply changes
            current['email'] = new_email
            # Write to database
            database.update_user(user_id, current)
            # Invalidate cache
            r.delete(f'user:{user_id}')
        finally:
            lock.release()

This ensures only one process can update a user's data at a time. No more conflicting updates.

4. Redis as a Distributed Lock

For critical data that absolutely must be consistent, Redis distributed locks prevent race conditions. Only one process can update a piece of data at a time.

```python import redis import time

r = redis.Redis()

def update_user_email(user_id, new_email): lock_key = f'lock:user:{user_id}' lock = r.lock(lock_key, timeout=10)

if lock.acquire(blocking=True):
    try:
        # Read current data
        current = database.get_user(user_id)
        # Apply changes
        current['email'] = new_email
        # Write to database
        database.update_user(user_id, current)
        # Invalidate

Comments

Questions, corrections, and tips stay visible for everyone reading this page.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.