Start Debugging

ASP.NET Core のマルチテナントキャッシュ: Redis のキープレフィックス vs 別データベース vs 別インスタンス

ほとんどのマルチテナント ASP.NET Core アプリでは、共有 Redis 1 台にテナントのキープレフィックスを付ける方式を使い、契約上の分離やノイジーなワークロードがあるテナントだけを専用インスタンスに移してください。番号付きデータベースは避けましょう。Redis Cluster、Azure Managed Redis、Redis Cloud ではデータベース 0 しか使えません。

マルチテナントの ASP.NET Core アプリでは、すべてのテナントを 1 台の共有 Redis に載せ、t:{tenantId}: のようなキープレフィックスで分離してください。この方式はどの Redis トポロジーでも動作し、HybridCache と IDistributedCache にそのまま組み込めます。しかも HybridCache のインプロセス L1 はテナント間で共有されるため、そもそもプレフィックスは必要になります。テナントに専用の Redis インスタンスを与えるのは、契約、コンプライアンス上の境界、またはノイジーなワークロードが求める場合だけにしてください。番号付きデータベース (SELECT 3) は避けましょう。Redis Cluster、Azure Managed Redis、Redis Cloud はデータベース 0 しかサポートしておらず、得られる分離も見た目ほど強くありません。

以下の内容はすべて、.NET SDK 10.0.302 で net10.0 を対象に、Microsoft.Extensions.Caching.StackExchangeRedis 10.0.12、Microsoft.Extensions.Caching.Hybrid 10.10.0、StackExchange.Redis 3.3.1 を使ってコンパイル確認しています。.NET 11 向けの 11.0.0-rc.1 パッケージにも同じ API があります。

3 つの選択肢の比較

キープレフィックス (共有インスタンス)テナントごとの番号付きデータベーステナントごとのインスタンス
テナントの分離方法すべてのキーの先頭に t:42: を付ける接続で SELECT 42 を実行するホストと認証情報を分ける
Redis Cluster / Azure Managed Redis / Redis Cloud で動作するかはいいいえ、データベース 0 のみはい
最大テナント数無制限既定で 16 (databases 設定)予算次第
メモリ上限とエビクション共有共有 (maxmemory はサーバー単位)別々
CPU とレイテンシの分離なしなし完全
テナントごとのアクセス制御ACL のキーパターン ~t:42:* (Redis 7 以降)Valkey 9.1 以降のみ (db= ルール)認証情報を分離
1 テナントを消去する方法SCAN + DEL、または HybridCache のタグFLUSHDBインスタンスを削除
1 回の AddHybridCache() で動作するかはいルーティング用の IDistributedCache が必要ルーティング用の IDistributedCache が必要
アプリインスタンスあたりの接続数マルチプレクサー 1 つ使用するデータベースごとにマルチプレクサー 1 つテナントごとにマルチプレクサー 1 つ
コスト最も低い最も低い最も高い

番号付きデータベースは中間の選択肢に見えます。しかし実際には、重要な制限はプレフィックス方式と同じものを共有したうえで、プレフィックス方式にはない制約が加わります。

番号付きデータベースが罠になる理由

Redis 自身の SELECT のドキュメントは率直です。データベースは “within the same application” でキーを分けるためのものであり、無関係なワークロードを 1 台のサーバーで動かすためのものではないとされています。さらに “Redis Cluster only supports database zero” と続きます。この 1 文だけで、マネージドサービスの大部分が対象外になります。

唯一の例外は Valkey です。Valkey 9.0 でクラスターモードでも番号付きデータベースが使えるようになり、Valkey 9.1 では db= ACL ルールが追加されて、ユーザーを特定のデータベースに制限できるようになりました。Valkey 9.1 以降を運用していて、他へ移る予定がないのであれば、データベースを使うのも妥当です。それ以外の環境でデータベースを選ぶと、テナンシーモデルが 1 つのホスティングトポロジーに縛られます。

データベースが使える環境でも、テナント同士が実際に奪い合うものは分離されません。maxmemory とエビクションポリシーはサーバー全体に適用されるため、1 つのテナントが自分のデータベースを埋め尽くすと、他のテナントのキーが追い出されます。Redis はコマンドを 1 本のメインスレッドで実行するので、あるテナントが遅い KEYS * を実行すると、すべてのデータベースが止まります。そして既定の databases 16 では、顧客が尽きる前にテナント枠が尽きます。

もう 1 つの現実的な問題は .NET 側にあります。AddStackExchangeRedisCache の裏にある IDistributedCache 実装の RedisCache は、RedisCache.cs で確認できるとおり、引数なしで connection.GetDatabase() を呼び出します。常に接続の DefaultDatabase を使うということです。データベース 7 にアクセスするには、DefaultDatabase = 7 を指定した ConfigurationOptions から RedisCache を作る必要があります。つまり使用するデータベースごとに ConnectionMultiplexer が 1 つ必要になり、共有マルチプレクサーが避けようとしているオーバーヘッドそのものになります。

キープレフィックス方式を正しく実装する

RedisCacheOptions.InstanceName は、“a single backend cache for use with multiple apps/services” を分割するための仕組みとしてドキュメントに記載されています。これはプロセス全体で共通のプレフィックスなので、アプリ名に使い、テナントは各キーに含めてください。

// .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();

テナントは信頼できる情報源から取得し、クライアントが操作できるヘッダーやクエリ文字列からは決して取得しないでください。ここでは、認証済みユーザーのクレームから取得しています。

// .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.");
}

重要なのは、アプリケーションコードが生のキャッシュキーを決して組み立てないことです。毎回テナントを付加するスコープ付きラッパーを経由させることで、開発者がテナントを付け忘れることがなくなります。

// .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);
}

Redis では、テナント 42 の商品エントリは myapp:t:42:product:7 というハッシュになります。タグにもプレフィックスを付けています。HybridCache のタグはグローバルな文字列なので、プレフィックスなしの RemoveByTagAsync("products") をあるテナントが呼ぶと、すべてのテナントの商品エントリが無効になってしまうからです。

カウンター、ロック、セットなどで IDatabase を直接使う場合は、StackExchange.Redis に 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

議論に決着をつける L1 の問題

どの方式を選んでもプレフィックスが必須になる理由が、この点です。HybridCache は 2 層のキャッシュで、その L1 はプロセス内のすべてのリクエストで共有されるインプロセスの MemoryCache です。テナント 42 を専用の Redis インスタンスにルーティングしつつ、キャッシュキーを単なる product:7 のままにしたとします。L2 の参照は正しいサーバーに向かいますが、L1 の参照が先に行われ、テナント 41 の product:7 がすでにメモリ上にあります。テナント 42 はテナント 41 の商品を受け取ってしまいます。

つまり、別データベースや別インスタンスにしても、テナント単位のキーが不要になるわけではありません。どのみち作らなければならない仕組みの上に、2 つ目の分離機構が加わるだけです。プレフィックスがある以上、残る問いは、一部のテナントにキー単位の分離以上のものが必要かどうかだけです。

出力キャッシュにも同じことが当てはまります。AddStackExchangeRedisOutputCache には独自の InstanceName があり、エントリの保存先がどこであっても、キャッシュキーはテナントごとに変化させる必要があります (テナントのクレームに対する VaryByValue)。

別インスタンスが適切な場面

テナントごとに Redis を分けるのは既定の選択ではありませんが、正当な階層です。次の場合に選んでください。

典型的な構成は、プール型にプレミアムなサイロ型を組み合わせたものです。全員が共有のプレフィックス付きインスタンスを使い、少数のテナントだけを専用インスタンスにマッピングします。AddHybridCache() が配線する L2 はちょうど 1 つなので、ルーティングは呼び出しごとにバックエンドを選ぶ IDistributedCache の中に置く必要があります。

// .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);
}

共有の RedisCache をキー付きサービスとして登録し、ルーターを HybridCache が解決するキーなしの IDistributedCache として登録してください。キーには引き続き TenantCache によるテナントプレフィックスが付くため、L1 は安全に保たれます。このルーターについて知っておくべき点が 2 つあります。1 つ目は HttpContext に依存するため、バックグラウンドジョブでは別の方法でテナントを確立する必要があることです (ジョブランナーが設定する AsyncLocal で対応できます)。2 つ目は基底インターフェースしか実装していないため、RedisCache の IBufferDistributedCache による高速パスを使えないことです。少数のプレミアムテナントであれば、このトレードオフは許容できます。

専用の RedisCache はそれぞれマルチプレクサーを 1 つ保持します。マルチプレクサーは共有して長期間使うことを前提に設計されているので、これらのインスタンスはプロセスの存続期間中キャッシュし、リクエストごとに作成しないでください。専用テナントが 20 個、アプリの Pod が 10 個あれば、Redis への接続が追加で 200 本になります。ここがテナントごとのインスタンスモデルのスケールの限界であり、既定ではなく 1 つの階層にとどめるべき理由です。

キープレフィックスの注意点

プレフィックスは必ず区切り文字で終わらせてください。 RedisCache は InstanceName とキーを区切り文字なしで連結します。HybridCache のキーに関するガイダンスに典型的な例があります。order{customerId}{orderId} とすると、顧客 42 の注文 123 と顧客 421 の注文 23 がどちらも order42123 になります。テナントでも同じことが起こります。t{tenant}{key} と書くと、テナント 7 とキー 42:profile、テナント 74 とキー 2:profile が衝突します。t:{tenant}: を使い、テナント ID に : が含まれないようにしてください。

テナントのキーの削除はコマンド 1 つではなく、スキャンです。 RemoveByTagAsync($"tenant:{id}") は手軽な方法ですが、論理的な無効化にすぎません。ドキュメントによると、値は “until they expire in the usual way” Redis に残ります。データを物理的に削除しなければならない場合 (オフボーディングや GDPR による消去)、すべてのプライマリをスキャンします。

// 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 は内部で SCAN を使い、クラスターでは対象ノード上のキーしか見えません。ループがすべてのエンドポイントを回しているのはそのためです。StackExchange.Redis のドキュメントは、負荷の高い本番サーバーでの実行に対して今も注意を促しているので、控えめなページサイズでバックグラウンドジョブとして実行してください。

テナントにハッシュタグを使わないでください。 キーを {t:42}:product:7 のように書くと、テナントのすべてのキーが 1 つのハッシュスロットに入り、複数キーの操作が可能になる一方で、テナント全体が 1 つのシャードに固定されます。最大のテナントがホットシャードになります。クロスキーのトランザクションが本当に必要な場合を除き、テナントを波括弧の外に置いてください。

テナントが Redis に直接アクセスする場合は ACL を追加してください。 通常は自分のアプリだけが Redis と通信するため、プレフィックスはコードレビューと TenantCache ラッパーで担保されます。テナント専用のワーカーに独自の認証情報を与える場合は、Redis 7 の ACL のキーパターンでサーバー側でもプレフィックスを強制できます。ACL SETUSER tenant42 on >secret ~myapp:t:42:* +@read +@write。

プレフィックスの長さはすべてのキーのオーバーヘッドになります。 myapp:t: に GUID のテナント ID を加えると、実際のキーに入る前に 44 バイトになります。小さなエントリが数千万件あるキャッシュでは、これは無視できないメモリ量です。短い整数や base-36 のテナント ID にすればオーバーヘッドはごくわずかで、HybridCache の MaximumKeyLength である 1024 文字からも大きく離れていられます。

推奨と、その理由

共有 Redis 上でキープレフィックスを使ってください。ローカルコンテナーから OSS クラスタリングの Azure Managed Redis まで、あらゆるトポロジーで動作し、1 回の AddHybridCache() 呼び出しと組み合わせられる唯一の選択肢であり、共有 L1 のためにどのみちテナントをキーに含める必要があります。契約やワークロードの面で分離にコストをかける価値のあるテナントには、上記のような IDistributedCache 経由でルーティングする専用インスタンスの階層を追加してください。Valkey 9.1 以降に腰を据えると決めていない限り、番号付きデータベースは避けましょう。FLUSHDB と INFO keyspace のデータベースごとのキー数は得られますが、メモリと CPU は他のすべてのテナントと共有したままで、クラスター構成やエンタープライズ版の Redis に移った瞬間に使えなくなります。

関連記事

参考資料

Comments

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

< 戻る