DEV Community

Yousra Saidani
Yousra Saidani

Posted on

Avoiding Stale Data: Redis Caching Gotchas

Caching data with Redis can lead to stale data if the right operations aren't used carefully βš οΈπŸ•°οΈ.

What is Redis? πŸš€

For those unfamiliar, Redis is an open-source, in-memory data structure store used as a database, cache, and message broker. It supports data structures such as strings, hashes, lists, sets, and more.

Its most appealing feature is speed β€” Redis keeps data in memory, making it incredibly fast for reads and writes. It's a popular choice for caching data that needs to be accessed quickly and frequently.

Redis Gotchas ⚠️🐍

One Redis operation, HSET, doesn’t allow you to set an expiry directly. You have to invoke a second operation, EXPIRE, to set a TTL (time-to-live) for the cache.

Gotcha: Calling HSET then EXPIRE without a transaction could lead to stale data if, for example, the EXPIRE command fails while the HSET succeeds.

HSET depends on the EXPIRE command to clear the cache and sync it with new data by reinvoking the backend.

➑️ If EXPIRE fails, the cached data will stay forever and become stale. If your app depends on fresh data, this could lead to showing outdated info. 😬

import redis

redis_client = redis.Redis(host='localhost', port=6379, db=0)

cities = ['Mississauga', 'Oakville']
for city in cities:
    key = f"weather_{city}"
    redis_client.hset(key, mapping={
        'temp': 22,
        'wind': 5,
        'condition': 'Cloudy',
        'city': city
    })
    redis_client.expire(key, 10)
Enter fullscreen mode Exit fullscreen mode

Β 

Why It Matters πŸ’‘

When caching, expiration is just as important as storing data. Stale data in a cache can be worse than no cache at all β€” it tricks your app into thinking it’s working with fresh info. πŸš«πŸ•°οΈ

Β 

The Solution: Use Transactions or Lua Scripts πŸ”πŸ‰

To ensure that both commands succeed or fail together, we should wrap them in a pipeline within a transaction (MULTI/EXEC) or use a Lua script.

A Redis pipeline allows multiple commands to be sent to the server as a single batch, reducing network overhead and minimizing the impact of network
glitches. For example, without pipelining, if two dependent write operations are executed separately, one might reach the Redis server while the other
could be lost due to a network issue. With pipelining, both operations are transmitted together in one network packet, making it more likely
that either both are received or both are dropped β€” offering a basic form of network-level consistency.

However, it's important to note that pipelining does not guarantee atomicity β€” commands are still executed one after the other by Redis, and
failures in individual commands do not roll back others.

To ensure true atomic execution, Redis transactions should be used. Using a transaction ensures that all operations within the block are executed
sequentially and without interference from other clients. The MULTI command queues the operations, and EXEC executes them all at once. This provides a
degree of isolation: no other client's commands are interleaved during the execution of the transaction.

transaction = redis_client.pipeline(transaction=True)
for city in cities:
    key = f"weather_{city}"
    transaction.hset(key, mapping={
        'temp': 22,
        'wind': 5,
        'condition': 'Cloudy',
        'city': city
    })
    transaction.expire(key, 10)

transaction.execute()
Enter fullscreen mode Exit fullscreen mode

Heads up: Redis transactions are not fully atomic. If one command fails, others won't be rolled back β€” partial changes can still persist.

True Atomicity with Lua Scripts πŸ§™β€β™‚οΈβœ¨

Redis guarantees atomicity when using Lua scripts. All operations run as one indivisible block β€” no other commands can run until the script finishes.

If the script completes, all changes are applied in order.

If there’s a runtime error, the entire script aborts and no changes are made.

But be careful! Lua scripts block the entire Redis server while running. If a script takes too long, it can slow down or freeze Redis for other clients.
So keep Lua scripts super fast .

cities = ['Mississauga', 'Oakville']
lua_script = """
for i = 1, #KEYS do
    local base = (i - 1) * 5
    local key = KEYS[i]
    redis.call('HSET', key, 'temp', ARGV[base + 1], 'wind', ARGV[base + 2], 'condition', ARGV[base + 3], 'city', ARGV[base + 4])
    redis.call('EXPIRE', key, tonumber(ARGV[base + 5]))
end
return #KEYS
"""

keys = [f"weather_{city}" for city in cities]

# For each city, pack values: temp=22, wind=5, condition='Cloudy', city=city, expire=10
args = []
for city in cities:
    args.extend(['22', '5', 'Cloudy', city, '10'])

redis_client.eval(lua_script, len(keys), *keys, *args)
Enter fullscreen mode Exit fullscreen mode

Final Thoughts πŸ’­

  • Always consider atomicity when working with Redis for critical caching.

  • Stale cache is worse than no cache β€” it can cause hidden bugs and confusing behavior.

  • Redis is fast and simple, but it can fail silently β€” wrap multi-step operations when consistency matters!

That's it for today!

Hope this article helped you avoid some Redis gotchas.

Happy coding and creating! βœ¨πŸš€

πŸ‘‰ You can also read this article on my website.

Top comments (0)