Caching

What flushing a cache tag actually deletes

The mistake

Cache::tags(['posts'])->flush() reads like a delete. You tag the entries when you write them. You flush the tag, and the entries with that tag are gone. The store gets smaller.

The Laravel docs describe it the same way: the flush “would remove” every entry with the tag. On Redis, the flush does remove them. On memcached, the flush deletes nothing. It makes the entries unreachable. They stay in the store, but no read can find them again. They use memory until their own time to live ends. An entry written with forever stays forever. After the flush, every read misses on both stores. A read cannot tell you which of the two happened.

And on a fresh Laravel app, Cache::tags() throws. The default cache store is database. The database store does not support tags.

The machine

Simulator · Cache tag flush

The keys, the tag ids and the tag sets run on the tested reducer, checked against Laravel 13 on a real Redis and a real memcached.

12:00: read 10 keys through their tags. 0 of 10 hit. 0 keys in the store.

Drive it

The panel opens on Redis. Ten posts were written under the posts tag, read, flushed, and read again. The last read hit 0 of 10. The big number counts the keys in the store. It reads 0.

  • Press “memcached”, then “Write post:1 to 10”, “Read all”, “Flush posts” and “Read all”. These are the same writes and the same flush. The last read hits 0 of 10 again. The big number reads 11: the ten posts and the tag id. The posts row is now marked unreachable. Press “Write post:1 to 10” once more, and the store holds 21 keys.
  • Press “Empty the store”, then “Write pinned, forever”, “Flush posts” and “Wait 1 hour”. The pinned entry is unreachable, and it never expires. Two keys stay in the store for good: the pinned entry and the new tag id.
  • Press “redis”, then “Write post:1 to 10” and “Wait 1 hour”. The ten posts expire on their own. The store holds one key: the tag’s sorted set, with 10 references that point at nothing. Press “Prune stale tags”, and the store is empty.

The mechanism

Every tagged entry is stored under a key built from its tags:

// TaggedCache::taggedItemKey()
return sha1($this->tags->getNamespace()).':'.$key;

The namespace is the ids of the entry’s tags, joined with |. What a flush does depends on how the store makes those ids.

On memcached, array and apc, a flush makes a new tag id. The first time the posts tag is used, Laravel writes a unique id to the key tag:posts:key. It writes that key with no expiry. A flush does not look for the entries. It writes a new unique id:

// TagSet::resetTag()
$this->store->forever($this->tagKey($name), $id = str_replace('.', '', uniqid('', true)));

From then on, every key under that tag has a new name. The old entries still sit in the store under the old names. Nothing can compute those names any more. Every read misses. Each old entry stays until its own time to live ends. An entry written with forever has no time to live, so it stays for good. Writing the same keys again stores them a second time beside the old ones.

On Redis, a tag id is a fixed string. The id for posts is always tag:posts:entries, so a flush never renames anything. That string is also the name of a Redis sorted set: a set where each member has a number, called its score. Every tagged write adds the entry’s key to the set of each of its tags. The score is the time the entry expires, or -1 for forever. A flush walks the set, deletes every key it lists, and removes each key from the set. The entries are gone and the memory comes back.

An entry that expires on its own leaves its reference behind. Redis deletes the entry when its time to live ends. Nothing removes its key from the set. A measured run wrote 1000 tagged entries with a five second time to live. Six seconds later the store held one key: the sorted set. The set still held 1000 references. Writing the same key again only updates its score. Each new key name adds a reference. A cache keyed by post id or user id adds a reference for every id it caches.

php artisan cache:prune-stale-tags removes the references whose score is in the past. References scored -1 stay, because a forever entry never goes stale. On memcached, array and database stores the command finds nothing to call. It still prints “Stale cache tags pruned successfully.”

An entry under two tags is lost when either tag is flushed. Write feed under tags(['posts', 'users']) and flush users. On memcached, the flush changes the users id. That changes the namespace of feed. The read misses. On Redis, the flush deletes feed and removes it from the users set. The posts set still lists it, as a reference to a key that is gone. A prune removes that reference after the entry’s expiry time has passed. If the entry was written with forever, the reference stays until posts is flushed.

A tag flush leaves untagged keys alone. Cache::flush() empties the whole store: untagged keys, orphans, tag ids and sorted sets.

The simulator’s tag ids are short and made up. Real ones come from uniqid(). Every count on this page was measured. A script runs Laravel 13.38 against a real Redis 7 and a real memcached 1.6. It counts keys in the servers themselves, not through Laravel. It checks, for example, the 11 keys after a memcached flush, the 0 keys after a Redis flush, and the 1000 references left behind by 1000 expired entries.

The cache stampede page covers the other way a cache surprises people: what happens when a key expires under load.

In your code

On Redis, a tag flush deletes what it should. The cost is the stale references in the sorted sets. Prune them on a schedule:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('cache:prune-stale-tags')->hourly();

On memcached, give every tagged entry a time to live. Then a flushed entry leaves the store when its time to live ends. Avoid forever together with tags, because a flushed forever entry stays in the store for good. Each flush that is followed by a refill leaves one more full copy of the tag’s entries in the store. The copies stay until their time to live ends.

Before you use tags at all, check CACHE_STORE in your .env. A fresh Laravel 13 app uses database. On that store, every call to Cache::tags() throws:

BadMethodCallException: This cache store does not support tagging.

The file store throws the same exception. The Laravel docs also list dynamodb and storage as stores without tags.

The fine print

  • Memcached evicts the least recently used items when it runs out of memory. Unreachable entries may therefore leave before their time to live ends. Eviction can also take a tag id key, such as tag:posts:key. The next read then writes a new id. Every entry under that tag becomes unreachable at once. This page did not measure eviction.
  • The array store behaves like memcached here. Both use the same TagSet class. The script measured both stores. apc uses the same class too, but it was not measured.
  • A Redis cluster takes a different path through the flush code. It was not measured.
  • The simulator leaves out the prefixes Laravel adds to every key, such as laravel-cache-.
  • Cache locks are covered on the cache stampede page, not here.

Further reading

Spotted a problem, or have a way to make this clearer? Suggest an improvement.