Start Debugging

Redis key prefix vs separate database vs separate instance for multi-tenant caching in ASP.NET Core

Use a tenant key prefix on one shared Redis for almost every multi-tenant ASP.NET Core app, move only tenants with contractual isolation or noisy workloads to their own instance, and skip numbered databases: Redis Cluster, Azure Managed Redis and Redis Cloud only have database 0.

For a multi-tenant ASP.NET Core app, put every tenant on one shared Redis and isolate them with a key prefix such as t:{tenantId}:. It works on every Redis topology, plugs straight into HybridCache and IDistributedCache, and the prefix is something you need anyway because HybridCache’s in-process L1 is shared across tenants. Give a tenant its own Redis instance only when a contract, a compliance boundary, or a noisy workload demands it. Avoid numbered databases (SELECT 3): Redis Cluster, Azure Managed Redis and Redis Cloud only support database 0, and the isolation they offer is weaker than it looks.

Everything below was compile-checked on .NET SDK 10.0.302 targeting net10.0, with Microsoft.Extensions.Caching.StackExchangeRedis 10.0.12, Microsoft.Extensions.Caching.Hybrid 10.10.0 and StackExchange.Redis 3.3.1. The same APIs exist in the 11.0.0-rc.1 packages for .NET 11.

The three options side by side

Key prefix, shared instanceNumbered database per tenantInstance per tenant
How a tenant is separatedt:42: in front of every keySELECT 42 on the connectionDifferent host and credentials
Works on Redis Cluster / Azure Managed Redis / Redis CloudYesNo, database 0 onlyYes
Max tenantsUnlimited16 by default (databases config)Your budget
Memory limit and evictionSharedShared (maxmemory is per server)Separate
CPU and latency isolationNoneNoneFull
Access control per tenantACL key patterns ~t:42:* (Redis 7+)Only Valkey 9.1+ (db= rule)Separate credentials
Wipe one tenantSCAN + DEL, or HybridCache tagFLUSHDBDelete the instance
Works with one AddHybridCache()YesNeeds a routing IDistributedCacheNeeds a routing IDistributedCache
Connections per app instance1 multiplexer1 multiplexer per database in use1 multiplexer per tenant
CostLowestLowestHighest

Numbered databases look like the middle ground. In practice they share every limit that matters with the prefix approach and add restrictions the prefix approach does not have.

Why numbered databases are the trap

Redis’s own SELECT documentation is blunt: databases are for separating keys “within the same application”, not for running unrelated workloads on one server. Then it adds that “Redis Cluster only supports database zero”. That single sentence rules out a large part of the managed market:

The only exception is Valkey: Valkey 9.0 added numbered databases in cluster mode, and Valkey 9.1 added db= ACL rules so a user can be restricted to specific databases. If you run Valkey 9.1+ and never plan to move off it, databases become defensible. Everywhere else, picking databases ties your tenancy model to one hosting topology.

Even where databases work, they do not isolate the things tenants actually fight over. maxmemory and the eviction policy apply to the whole server, so one tenant filling its database evicts keys from everyone else’s. Redis executes commands on one main thread, so a tenant running a slow KEYS * stalls all databases. And with the default databases 16, you run out of tenants before you run out of customers.

The other practical problem is on the .NET side. RedisCache, the IDistributedCache implementation behind AddStackExchangeRedisCache, calls connection.GetDatabase() with no argument, as you can see in RedisCache.cs. It always talks to the connection’s DefaultDatabase. To reach database 7 you need a RedisCache built from a ConfigurationOptions with DefaultDatabase = 7, which means a separate ConnectionMultiplexer per database in use, which is exactly the overhead the shared multiplexer is supposed to avoid.

The key prefix approach, done properly

RedisCacheOptions.InstanceName is documented as a way to partition “a single backend cache for use with multiple apps/services”. It is a process-wide prefix, so use it for the app name and put the tenant into each key:

// .NET 10, ASP.NET Core 10
// Microsoft.Extensions.Caching.StackExchangeRedis 10.0.12
// Microsoft.Extensions.Caching.Hybrid 10.10.0, StackExchange.Redis 3.3.1
using Microsoft.Extensions.Caching.Hybrid;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

var mux = await ConnectionMultiplexer.ConnectAsync(
    builder.Configuration.GetConnectionString("redis")!);
builder.Services.AddSingleton<IConnectionMultiplexer>(mux);

builder.Services.AddStackExchangeRedisCache(o =>
{
    // share the multiplexer instead of opening a second connection
    o.ConnectionMultiplexerFactory = () => Task.FromResult<IConnectionMultiplexer>(mux);
    o.InstanceName = "myapp:"; // note the trailing delimiter
});
builder.Services.AddHybridCache();

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, ClaimTenantContext>();
builder.Services.AddScoped<TenantCache>();

var app = builder.Build();

app.MapGet("/products/{id:int}", async (int id, TenantCache cache) =>
    await cache.GetOrCreateAsync($"product:{id}",
        ct => ValueTask.FromResult($"product {id}")));

app.Run();

The tenant comes from a trusted source, never from a header or query string the client controls. Here it is a claim on the authenticated user:

// .NET 10, C# 14
public interface ITenantContext { string TenantId { get; } }

public sealed class ClaimTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
    public string TenantId =>
        accessor.HttpContext?.User.FindFirst("tenant_id")?.Value
        ?? throw new InvalidOperationException("No tenant on this request.");
}

The important part is that application code never builds a raw cache key. It goes through a scoped wrapper that adds the tenant every time, so a developer cannot forget it:

// .NET 10, C# 14, Microsoft.Extensions.Caching.Hybrid 10.10.0
public sealed class TenantCache(HybridCache cache, ITenantContext tenant)
{
    private string Key(string key) => $"t:{tenant.TenantId}:{key}";
    private string TenantTag => $"tenant:{tenant.TenantId}";

    public ValueTask<T> GetOrCreateAsync<T>(
        string key,
        Func<CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? tags = null,
        CancellationToken ct = default) =>
        cache.GetOrCreateAsync(
            Key(key),
            factory,
            static (f, c) => f(c),
            options,
            [TenantTag, .. (tags ?? []).Select(t => $"{TenantTag}:{t}")],
            ct);

    public ValueTask RemoveAsync(string key, CancellationToken ct = default) =>
        cache.RemoveAsync(Key(key), ct);

    // logical wipe of everything this tenant cached
    public ValueTask InvalidateTenantAsync(CancellationToken ct = default) =>
        cache.RemoveByTagAsync(TenantTag, ct);
}

In Redis the product entry for tenant 42 ends up as the hash myapp:t:42:product:7. Tags are prefixed too: HybridCache tags are global strings, so an unprefixed RemoveByTagAsync("products") from one tenant would invalidate every tenant’s product entries.

If you use IDatabase directly for counters, locks or sets, StackExchange.Redis has the same idea built in through StackExchange.Redis.KeyspaceIsolation:

// StackExchange.Redis 3.3.1
using StackExchange.Redis.KeyspaceIsolation;

IDatabase tenantDb = mux.GetDatabase().WithKeyPrefix($"myapp:t:{tenantId}:");
await tenantDb.StringIncrementAsync("logins"); // writes myapp:t:42:logins

The L1 problem that settles the debate

Here is the detail that makes the prefix mandatory, whichever option you pick. HybridCache is a two-level cache, and its L1 is an in-process MemoryCache shared by every request in the process. Suppose you route tenant 42 to its own Redis instance and keep the cache key as plain product:7. The L2 lookup goes to the right server, but the L1 lookup happens first, and product:7 from tenant 41 is already sitting in memory. Tenant 42 gets tenant 41’s product.

So separate databases and separate instances do not remove the need for tenant-scoped keys. They add a second isolation mechanism on top of the one you still have to build. Once the prefix exists, the remaining question is only whether some tenants need more than key-level separation.

The same applies to output caching. AddStackExchangeRedisOutputCache has its own InstanceName, and the cache key must vary by tenant (VaryByValue on the tenant claim) regardless of where the entries are stored.

When a separate instance is the right call

A separate Redis per tenant is not the default, but it is a legitimate tier. Pick it when:

The usual shape is a pool model with a premium silo: everyone on the shared, prefixed instance, a short list of tenants mapped to dedicated instances. Because AddHybridCache() wires exactly one L2, the routing has to live in an IDistributedCache that picks the backend per call:

// .NET 10, C# 14, Microsoft.Extensions.Caching.StackExchangeRedis 10.0.12
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;

public sealed class TenantRoutingCache(
    IHttpContextAccessor accessor,
    IConfiguration config,
    [FromKeyedServices("shared")] IDistributedCache shared) : IDistributedCache
{
    private readonly ConcurrentDictionary<string, IDistributedCache> _dedicated = new();

    private IDistributedCache Current()
    {
        var tenant = accessor.HttpContext?.User.FindFirst("tenant_id")?.Value;
        var cs = tenant is null ? null : config[$"Tenants:{tenant}:Redis"];
        if (cs is null) return shared;

        return _dedicated.GetOrAdd(tenant!, _ => new RedisCache(
            new RedisCacheOptions { Configuration = cs, InstanceName = "myapp:" }));
    }

    public byte[]? Get(string key) => Current().Get(key);
    public Task<byte[]?> GetAsync(string key, CancellationToken token = default) =>
        Current().GetAsync(key, token);
    public void Set(string key, byte[] value, DistributedCacheEntryOptions options) =>
        Current().Set(key, value, options);
    public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options,
        CancellationToken token = default) => Current().SetAsync(key, value, options, token);
    public void Refresh(string key) => Current().Refresh(key);
    public Task RefreshAsync(string key, CancellationToken token = default) =>
        Current().RefreshAsync(key, token);
    public void Remove(string key) => Current().Remove(key);
    public Task RemoveAsync(string key, CancellationToken token = default) =>
        Current().RemoveAsync(key, token);
}

Register the shared RedisCache as a keyed service and the router as the unkeyed IDistributedCache that HybridCache resolves. The keys still carry the tenant prefix from TenantCache, which keeps L1 safe. Two things to know about this router: it depends on HttpContext, so background jobs must establish the tenant some other way (an AsyncLocal set by the job runner works), and it bypasses RedisCache’s IBufferDistributedCache fast path because it only implements the base interface. For a handful of premium tenants that trade-off is fine.

Each dedicated RedisCache owns one multiplexer. A multiplexer is designed to be shared and long-lived, so cache these instances for the life of the process and never create them per request. With 20 dedicated tenants and 10 app pods you are holding 200 extra Redis connections, which is where the per-tenant instance model stops scaling and why it should stay a tier rather than the default.

Gotchas with key prefixes

Always end the prefix with a delimiter. RedisCache concatenates InstanceName and the key with no separator. The HybridCache key guidance gives the classic example: order{customerId}{orderId} makes customer 42 with order 123 and customer 421 with order 23 both order42123. The same thing happens with tenants: tenant 7 plus key 42:profile and tenant 74 plus key 2:profile collide if you write t{tenant}{key}. Use t:{tenant}: and make sure tenant ids cannot contain :.

Deleting a tenant’s keys is a scan, not a command. RemoveByTagAsync($"tenant:{id}") is the cheap option, but it is a logical invalidation: the docs say values stay in Redis “until they expire in the usual way”. When you must physically remove data (offboarding, GDPR erasure), scan every primary:

// StackExchange.Redis 3.3.1
public static async Task<long> PurgeTenantAsync(IConnectionMultiplexer mux, string tenantId)
{
    var db = mux.GetDatabase();
    long deleted = 0;
    foreach (var endpoint in mux.GetEndPoints())
    {
        var server = mux.GetServer(endpoint);
        if (server.IsReplica) continue;

        await foreach (var key in server.KeysAsync(pattern: $"myapp:t:{tenantId}:*", pageSize: 500))
        {
            // one DEL per key: in a cluster, keys from one node can span many hash slots
            if (await db.KeyDeleteAsync(key)) deleted++;
        }
    }
    return deleted;
}

KeysAsync uses SCAN under the hood, and in a cluster it only sees keys on that node, which is why the loop goes over every endpoint. The StackExchange.Redis docs still warn against running it on busy production servers, so do it in a background job with a modest page size.

Do not use hash tags for tenants. Writing keys as {t:42}:product:7 forces all of a tenant’s keys into one hash slot, which allows multi-key operations but also pins the whole tenant to one shard. Your largest tenant becomes a hot shard. Leave the tenant out of braces unless you really need cross-key transactions.

Add ACLs if tenants have direct Redis access. Normally only your app talks to Redis, so the prefix is enforced by code review and the TenantCache wrapper. If a tenant-specific worker gets its own credentials, Redis 7 ACL key patterns enforce the prefix server-side: ACL SETUSER tenant42 on >secret ~myapp:t:42:* +@read +@write.

Prefix length is overhead on every key. myapp:t: plus a GUID tenant id is 44 bytes before the actual key. On a cache with tens of millions of small entries that is real memory. A short integer or base-36 tenant id keeps it negligible, and keeps you far from HybridCache’s 1024-character MaximumKeyLength.

The recommendation, with the reasons

Use a key prefix on a shared Redis. It runs on every topology from a local container to Azure Managed Redis with OSS clustering, it is the only option that composes with a single AddHybridCache() call, and the tenant has to be in the key anyway because of the shared L1. Add a dedicated-instance tier, routed through an IDistributedCache like the one above, for the tenants whose contracts or workloads justify paying for isolation. Skip numbered databases unless you are committed to Valkey 9.1+: they give you FLUSHDB and per-database key counts in INFO keyspace, but they share memory and CPU with every other tenant and they disappear the moment you move to a clustered or enterprise Redis.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back