Support ticket: a user changed his name, the site still shows the old one. The profile sits in Redis with TTL one hour. Fine, we set five minutes. A week later the hit rate drops and MySQL feels it. This seesaw has no good position. TTL is insurance, not invalidation.

The obvious upgrade is cache-aside with explicit delete. Read: try the cache, miss, load from the database, write to the cache. Write: update the row, delete the key. Looks correct. It has a race.

Process A gets a miss and reads the old row. Process B updates the row and deletes the key. Then A, a bit slow, writes the old value into the cache. Fresh delete, stale data on top of it, and it will sit there happily. This is why the TTL stays even with explicit invalidation: it limits how long a lost race can hurt you.

Another road is versioned keys. Delete nothing, change the address:

$v = $redis->get('user:42:v') ?: 1;
$profile = $redis->get("user:42:profile:$v");
// on update: $redis->incr('user:42:v');

INCR is atomic, so the write race is gone. Old generations are garbage and expire by TTL. As a bonus, put one version in front of a whole family of keys, bump it once, the whole group is invalid. Poor man’s cache tags.

Write-through and event-driven invalidation exist too, and they are fine, but they are infrastructure. A consumer that listens to entity-changed events and cleans keys is a service you now operate and get paged for.

My default is boring: cache-aside, explicit delete, versioned keys where the race matters, and always a TTL as the last line.

I still remember that ticket. The user was right and the cache was correct by design. Both were true for one hour.