Caching a query result in Redis is easy. Wrap the query, store the result under a key, give it a TTL, and reads get faster.
The harder part starts when the data changes. How do stale entries disappear? One simple answer is to find the matching keys and delete them. That works at small scale, but it does not scale well. It also shifts the cost to every other client using the same Redis instance, instead of keeping it on the write that caused the change. Let's look at why we invalidate by incrementing a version counter instead. We'll also look at the details that make this practical: scoping, stampede control, and a boot-time check for the one setup that can turn a cache into a data leak.
Let's say cached entries for organization types share a prefix like orgtypes:*. Someone edits an org type, and every cached version of that list has to go. The direct approach is to find those keys by pattern and delete them:
var keys = server.Keys(pattern: "orgtypes:*");
foreach (var key in keys)
await db.KeyDeleteAsync(key);
That feels fine in development, where the keyspace is small. In production, the trade changes:
KEYS is O(N) over the whole keyspace, and Redis runs commands on a single thread. While it walks millions of keys to find a few hundred, it is not serving the other clients.SCAN is better, but it does not remove the scaling problem. It spreads the work over many calls instead of one long block. But it is still O(N), it can miss or repeat keys that change during the scan, and every match still has to be deleted.KEYS and SCAN run per node, so a pattern sweep across a sharded keyspace means calling every node and merging the results.The deeper problem is the idea underneath all of this: that to invalidate an entry, you must find it and remove it.
An entry does not have to be deleted to become invalid. It only has to become unreachable. If new reads stop building the key that points to it, the old entry becomes unused data and Redis removes it later when the TTL expires.
That is the idea behind a version counter. The version goes into the key, and a counter stores the current version:
key = q:{name}:v{version}:{argsHash}
counter = ver:{name} -> a Redis integer; a missing counter reads as 0
INCR ver:{name}.The increment does not touch any cache entry. Reads after it build v{version+1}, miss, and repopulate from source. The older generation is no longer reachable to new reads and expires on its own. Invalidation drops from an O(N) keyspace scan to one O(1) increment. It also fits Redis Cluster more naturally, because invalidation updates one counter key instead of scanning for a pattern across nodes.
In a .NET service, a natural place for this is a MediatR pipeline behavior. That way, caching is declared on the request instead of being repeated inside every handler:
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var spec = CacheAttribute.For(request); // { Name, Scope } or null
if (spec is null)
return await next(); // not cached — pass through
var version = await _redis.GetCounterAsync(VersionKey(spec)); // 0 when absent
var key = $"q:{spec.Name}:v{version}:{Hash(request)}{ScopeSuffix(spec)}";
if (await _redis.GetAsync<TResponse>(key) is { } hit)
return hit;
var result = await next();
await _redis.SetAsync(key, result, ttl: TimeSpan.FromMinutes(30));
return result;
}
static string VersionKey(CacheSpec s) => $"ver:{s.Name}{ScopeSuffix(s)}";
static string Hash(object request)
{
var json = JsonSerializer.Serialize(request);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(bytes)[..16].ToLowerInvariant(); // arguments fingerprint
}
Two small details matter here.
First, a missing counter reads as 0. So there is no setup step. A new cache name works right away, and the first INCR moves it to 1.
Second, the arguments hash gives every distinct set of parameters its own entry. You do not have to manage all those keys by hand.
One more small note is worth adding. Sixteen hex characters is 64 bits. That means two different requests could, in theory, produce the same fingerprint and share an entry. If that risk matters for a cache with a very large or user-controlled parameter space, keep more of the digest.
Queries declare what they cache. Commands can declare what they invalidate the same way. After a command succeeds, the behavior bumps the right counters:
// after a successful command, bump the version for every cache name it invalidates
var scopeSuffix = ScopeSuffixFor(request);
foreach (var name in ResolveNames(InvalidatesAttribute.For(request)))
await _redis.IncrementAsync($"ver:{name}{scopeSuffix}"); // O(1)
[Cache("orgtypes.list", CacheScope.Organization)]
public record ListOrgTypes(Guid OrgId) : IRequest<IReadOnlyList<OrgType>>;
[Invalidates("orgtypes")]
public record CreateOrgType(Guid OrgId, string Name) : IRequest<Result>;
The key point is that the query and the command must build the same version key: ver:{name}{scope}.
This is easy to miss because each side can look correct on its own.
In the example above, two small pieces make that happen:
orgtypes is a group name, not the final cache key. ResolveNames expands it to the real cache names under that group, such as orgtypes.list or orgtypes.get. Without this step, the command would increment ver:orgtypes, and no read would ever look at that counter.The failure is subtle: the command increments a real Redis key, just not the key the query reads.
Invalidation runs only on success. A command that throws, or returns a failure, leaves the counters and the cache alone. A half-failed write should not clear a still-valid cache.
One counter per cache name is too broad. If one organization's data changes, you do not want to invalidate the cached list for every tenant.
The fix is to scope the counter. Give each cache a scope, add that scope to both the entry key and the version key, and each slice gets its own counter:
static string ScopeSuffix(CacheSpec s) => s.Scope switch
{
CacheScope.All => "",
CacheScope.Organization => $":org:{s.OrgId}",
CacheScope.Unit => $":unit:{s.UnitId}",
CacheScope.User => $":user:{s.UserId}",
_ => throw new UnreachableException()
};
If the cache name is orgtypes.list, then ver:orgtypes.list:org:{A} and ver:orgtypes.list:org:{B} are different counters. Invalidating org A only bumps A's counter. Org B's cached reads stay warm.
Scopes can also form a hierarchy — All, Organization, Unit, User — and a broad invalidation can fan out to everything below it if you declare those dependencies. But that only works if the cache declares the right scope in the first place. If the scope is wrong, the isolation is wrong too. The next section covers that failure.
The next failure usually shows up only under load: the recompute stampede. A hot cache is invalidated, many requests read the new version at almost the same time, all of them miss, and all of them run the underlying query.
The fix is single-flight: per key, one request recomputes while the others wait.
var gate = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct);
try
{
if (await _redis.GetAsync<TResponse>(key) is { } hit) // double-check inside the lock
return hit;
var result = await next();
await _redis.SetAsync(key, result, ttl);
return result;
}
finally { gate.Release(); }
The second cache check inside the lock is what makes this work. The first request computes and stores the value. The requests waiting behind it wake up, see the fresh entry, and return without hitting the source again.
This lock is per key and process-local. So it collapses a herd inside one instance, but it does not coordinate across the whole fleet. If ten instances all miss the same hot key, you can still get up to ten recomputes. That is usually an acceptable trade, because the alternative is a distributed lock in front of every cache miss.
The lock map has a memory cost too. _locks gets one SemaphoreSlim the first time it sees a key, and it never drops it. So a cache with many distinct keys can keep growing this map. If that matters, use a fixed set of lock slots and hash each key into one slot. That allows a little extra waiting, but memory no longer grows with the number of keys.
One misconfiguration is worth catching at startup, not later, because it is both a correctness bug and a security bug.
Take a query cached at the broadest scope, CacheScope.All, whose handler reads the current user:
[Cache("my.dashboard", CacheScope.All)] // one shared entry for everyone
public record GetMyDashboard() : IRequest<Dashboard>;
// handler:
var me = _currentUser.Id; // ...but the result is per-user
return await BuildDashboardFor(me);
User A loads their dashboard, and it is cached under one shared key. User B loads theirs, hits the same key, and gets User A's dashboard. The cache says "shared," but the handler really depends on the current user.
This bug often passes tests, because one-user tests do not show it. It usually appears only with multiple users and a warm cache.
Do not rely only on code review to catch this. Catch it at startup too. Walk every cached query at boot, and refuse to start when one is cached at CacheScope.All while its handler depends on the current user:
foreach (var q in CachedQueries(scope: CacheScope.All))
{
if (q.HasAttribute<CacheScopeVerified>()) continue; // explicit, reviewed opt-out
if (HandlerDependsOn<ICurrentUser>(q))
errors.Add($"{q.Name}: cached at CacheScope.All but the handler reads ICurrentUser " +
"— per-user data in a shared key.");
}
if (errors.Count > 0)
throw new CacheConfigurationException(errors); // fail closed, at boot
That check is a useful guardrail, not a proof. It is good at catching the direct pattern above. But if the handler reaches the current user through a helper method, a service call, or a composed expression, the scan may miss it. It can also flag a safe handler that only reads the user for some other reason. That is why the opt-out attribute exists. Use the check to catch the common mistake cheaply, not as a guarantee that every shared cache is safe.
Version-counter invalidation does not remove the cost. It moves it. The trade-offs are straightforward:
INCR, the old generation stays in Redis until its TTL ends. A frequently invalidated cache can hold several old generations for a while.INCR can still populate the old generation. For most caches this is acceptable. If your system must show a write on the very next read, the cache cannot be the thing that guarantees that.What you get back is simple: invalidation stops being an O(N), cluster-unfriendly sweep and becomes one atomic increment. On any cache large enough for KEYS to hurt, that trade is usually worth it.
If the cached keyspace is small and invalidations are rare, a SCAN-and-delete is fine. There is no reason to build a versioning scheme for a problem you do not have. And if "invalidate" really means "this data must be provably and immediately gone" — for example, after a deletion request under GDPR — version counters are the wrong tool. They leave old entries in Redis until TTL removes them. That case needs an explicit delete.
For the common case — many cached query variants, frequent invalidation, a growing keyspace, and eventually a cluster — the counter earns its place. It replaces the operation that scales worst, a keyspace scan, with one atomic increment. It also makes invalidation something you declare on purpose, not something you rebuild from key prefixes during an incident.
That is the trade at the heart of it: a bounded amount of old data waiting for TTL, in exchange for invalidation that is no longer a latency problem for every other client.
The code examples from this article are available in the GitHub repo.
Turkish version: Türkçe okuyun