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>>());
    });
}