I'm a heavy user of Redis. On a recent project, we cached a bunch of content that had the following key structure:
content-{company-id}-checkout-...
The requirement was that we needed to drop all keys starting with the above prefix when any changes were made to the company in our CMS.
We're using IDistributedCache with a Redis implementation based on the StackExchange.Redis package.
The IDistributedCache interface does not provide any "remove by prefix" methods:
Get, GetAsync: Accepts a string key and retrieves a cached item as a byte[] array if found in the cache.
Set, SetAsync: Adds an item (as byte[] array) to the cache using a string key.
Refresh, RefreshAsync: Refreshes an item in the cache based on its key, resetting its sliding expiration timeout (if any).
Remove, RemoveAsync: Removes a cache item based on its string key.
To enable support for removing all keys with a certain prefix, I created the following interface:
// The name is not `IMyDistributedCache` IRL, just for this post.
public interface IMyDistributedCache : IDistributedCache
{
Task<(long ItemsDeleted, long ItemsFound)> RemoveItemsByPrefix(string prefix);
}
With the following implementation:
public class RedisMyDistributedCache : RedisCache, IMyDistributedCache
{
private static readonly LuaScript RemoveByPrefixScript;
private readonly RedisDatabaseProvider _redisDatabaseProvider;
private readonly RedisCacheOptions _redisCacheOptions;
static RedisMyDistributedCache()
{
var script =
"""
local prefix = @prefix
local cursor = '0'
local count = 0
local batch_size = 500
local scanned = 0
repeat
local result = redis.call('SCAN', cursor, 'MATCH', prefix .. '*', 'COUNT', batch_size)
cursor = result[1]
local keys = result[2]
scanned = scanned + #keys
if #keys > 0 then
local deleted = redis.call('DEL', unpack(keys))
count = count + deleted
end
until cursor == '0'
return {count, scanned}
""";
RemoveByPrefixScript = LuaScript.Prepare(script);
}
public RedisMyDistributedCache(
RedisDatabaseProvider redisDatabaseProvider,
IOptions<RedisCacheOptions> optionsAccessor) : base(optionsAccessor)
{
_redisDatabaseProvider =
redisDatabaseProvider ?? throw new ArgumentNullException(nameof(redisDatabaseProvider));
_redisCacheOptions = optionsAccessor.Value ?? throw new ArgumentNullException(nameof(optionsAccessor));
}
public async Task<(long ItemsDeleted, long ItemsFound)> RemoveItemsByPrefix(string prefix)
{
if(!string.IsNullOrWhiteSpace(_redisCacheOptions.InstanceName))
{
prefix = $"{_redisCacheOptions.InstanceName}{prefix}";
}
var database = await _redisDatabaseProvider.GetDatabase();
var result =
await database.ScriptEvaluateAsync(
RemoveByPrefixScript, new { prefix = prefix }, CommandFlags.DemandMaster);
if (result.Resp3Type == ResultType.Array)
{
var array = (RedisValue[])result!;
return ((long)array[0], (long)array[1]);
}
return ((long)result, 0);
}
}
public class RedisDatabaseProvider
{
private readonly Lazy<Task<IDatabaseAsync>> _database;
public RedisDatabaseProvider(ConnectionMultiplexerProvider connectionMultiplexerProvider)
{
_database = new Lazy<Task<IDatabaseAsync>>(async () =>
{
var connectionMultiplexer = await connectionMultiplexerProvider.GetConnectionMultiplexer();
return connectionMultiplexer.GetDatabase();
});
}
public async Task<IDatabaseAsync> GetDatabase()
{
return await _database.Value;
}
}
As you can see, 'm inheriting from the RedisCache, which is the original implementation of IDistributedCache. I'm also implementing the new interface IMyDistributedCache.
The key part here is the LUA-script. Redis supports running LUA-scripts whenever you need to do something that is not supported out of the box.
How it works:
- SCAN loop - Iterates through the keyspace in batches of 500 using SCAN (non-blocking, unlike KEYS)
- Pattern matching - Finds keys matching prefix*
- Batch deletion - Deletes found keys with DEL
- Continues until cursor returns to '0' (full scan complete)
It returns the total keys deleted (and found).
Why SCAN instead of KEYS? — KEYS blocks Redis on large datasets; SCAN is incremental and production-safe.
One thing to note here, that is not a problem for us since we only have about ~50 keys in total matching a given prefix and they are always created at the same time, is that the operation is not atomic.
New keys matching the prefix could, theoretically, be added mid-execution for example. If you want it to be atomic you would have to use KEYS instead of SCAN, but remember that KEYS is a blocking operation and it's not recommended in production (unless you really know what you're doing).
The registration of the IMyDistributedCache looks like this:
public static void AddCaching(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<RedisDistributedCacheOptions>(_ =>
{
var redisDistributedCacheOptions = new RedisDistributedCacheOptions();
configuration.GetSection("Redis").Bind(redisDistributedCacheOptions);
return redisDistributedCacheOptions;
});
services.AddSingleton<ConfigurationOptions>(x =>
{
var redisDistributedCacheOptions = x.GetRequiredService<RedisDistributedCacheOptions>();
var configurationOptions = new ConfigurationOptions
{
Password = redisDistributedCacheOptions.Password, AllowAdmin = true
};
if(redisDistributedCacheOptions.UseSentinel)
{
configurationOptions.ServiceName = redisDistributedCacheOptions.ServiceName;
}
foreach(var dnsEndPoint in redisDistributedCacheOptions.GetEndpoints())
{
configurationOptions.EndPoints.Add(dnsEndPoint.Host, dnsEndPoint.Port);
}
return configurationOptions;
});
services.AddSingleton<ConnectionMultiplexerProvider>();
services.AddSingleton<RedisDatabaseProvider>();
services.AddSingleton<RedisCacheOptions>(x =>
{
var connectionMultiplexerProvider = x.GetRequiredService<ConnectionMultiplexerProvider>();
var redisDistributedCacheOptions = x.GetRequiredService<RedisDistributedCacheOptions>();
var configurationOptions = x.GetRequiredService<ConfigurationOptions>();
return new RedisCacheOptions
{
ConfigurationOptions = configurationOptions,
ConnectionMultiplexerFactory = () => connectionMultiplexerProvider.GetConnectionMultiplexer(),
InstanceName = redisDistributedCacheOptions.KeyPrefix
};
});
services.AddSingleton<IMyDistributedCache>(x =>
{
var redisDatabaseProvider = x.GetRequiredService<RedisDatabaseProvider>();
var redisCacheOptions = x.GetRequiredService<RedisCacheOptions>();
return new LoggingMyDistributedCache(
new RedisMyDistributedCache(redisDatabaseProvider, redisCacheOptions),
x.GetRequiredService<ILogger<LoggingMyDistributedCache>>());
});
}