Start Debugging

.NET 11 における HybridCache vs IMemoryCache vs IDistributedCache: どれを選ぶべきか?

.NET 11 の新しいキャッシュコードでは、デフォルトで HybridCache を使ってください。IMemoryCache はシリアライズ不要で単一サーバーの速度が必要なときだけ、IDistributedCache はバッキングストアとしてのみ選びます。これが判断のためのマトリクスです。

.NET 11 の新しいキャッシュコードでは、デフォルトで HybridCache を使ってください。IMemoryCache のインプロセスの速度、IDistributedCache のサーバー間のリーチ、そしてどちらの旧来の API も持たないキャッシュスタンピード保護とタグによる無効化を、すべて 1 回の GetOrCreateAsync 呼び出しの背後で提供します。素の IMemoryCache を選ぶのは、シリアライズなしで単一サーバーのレイテンシと、きめ細かい削除制御が必要なときだけにしてください。素の IDistributedCache を選ぶのは、主に L1 層なしの分散ストアが必要なとき(または HybridCache のバッキング層として)です。この記事では、その推奨を完全な機能マトリクス、実際に効いてくる API の違い、そしてあなたの代わりに判断を下す決め手で裏付けます。

ここで扱う内容はすべて .NET 11、ASP.NET Core 11、C# 14 を対象としています。HybridCacheMicrosoft.Extensions.Caching.Hybrid パッケージで提供され、これは .NET 9 とともに GA となり、.NET 11 で使うものと同じパッケージです。.NET Framework 4.7.2 や .NET Standard 2.0 までのランタイムをサポートするため、以下の比較は最新の TFM に限定されません。

機能マトリクス

機能IMemoryCacheIDistributedCacheHybridCache
L1(インプロセス)L2(アウトオブプロセス)L1 + オプションの L2
サーバー間で共有いいえはいはい(L2 経由)
プロセス再起動を生き延びるいいえはいL2 は生き延び、L1 は生き延びない
格納形式生のオブジェクトbyte[]L1 ではオブジェクト、L2 ではシリアライズ済み
シリアライズなし自分で書く組み込み(System.Text.Json ほか)
キャッシュスタンピード保護なしなしあり
タグによる無効化なしなしあり(RemoveByTagAsync
1 回の呼び出しで取得または生成拡張のみ、ガードなしなしあり(GetOrCreateAsync
エントリ単位の有効期限制御完全絶対 + スライディング全体 + ローカル(LocalCacheExpiration
組み込みの OpenTelemetry メトリクスあり(.NET 11)バックエンド次第あり
標準同梱(NuGet 不要)はい抽象はあり、バックエンドはなしいいえ(1 パッケージ)
最小ランタイム広い広い.NET Framework 4.7.2 / netstandard2.0

3 つとも DI 経由で登録され、インターフェース(HybridCache の場合は抽象クラス)で解決されます。重要な違いは登録にあるのではなく、キャッシュミス時と並行アクセス下で各々が何をするかにあります。

各 API が実際に何であるか

IMemoryCache は、生のオブジェクト参照をプロセス内の ConcurrentDictionary ベースのストアに格納します。シリアライズはありません。Customer を入れれば、同じ Customer 参照が返ってきます。これにより 3 つの中で最速になり、キャッシュヒットが本質的に辞書の参照のコストで済む唯一のものになります。代償は、プロセス単位であることです。ロードバランサーの背後にある 2 つのインスタンスは 2 つの独立したキャッシュを持ち、再起動すると空になります。

// .NET 11, C# 14
builder.Services.AddMemoryCache();

public class ProductService(IMemoryCache cache, ProductDb db)
{
    public Task<Product> GetAsync(int id) =>
        cache.GetOrCreateAsync($"product:{id}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return db.LoadProductAsync(id);
        })!;
}

IDistributedCache は、アウトオブプロセスのストアに対する意図的に低レベルな抽象です。その表面は GetAsyncSetAsyncRefreshAsyncRemoveAsync(および同期版)で、すべての値は byte[] です。GetOrCreate はなく、オブジェクトモデルもなく、並行制御もありません。シリアライズ、キー命名、有効期限ポリシー、リードスルーのパターンは自分で担います。

// .NET 11, C# 14
builder.Services.AddStackExchangeRedisCache(o =>
    o.Configuration = builder.Configuration.GetConnectionString("Redis"));

public class ProductService(IDistributedCache cache, ProductDb db)
{
    public async Task<Product> GetAsync(int id)
    {
        var key = $"product:{id}";
        var bytes = await cache.GetAsync(key);
        if (bytes is not null)
            return JsonSerializer.Deserialize<Product>(bytes)!;

        var product = await db.LoadProductAsync(id);
        await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(product),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            });
        return product;
    }
}

これはキャッシュする値ごとに約 15 行のボイラープレートであり、コピーするたびに有効期限を忘れたり、null の扱いを誤ったり、わずかに異なるシリアライザーを選んだりする機会になります。組み込みの実装には、インメモリ(AddDistributedMemoryCache。実際には分散ではないため、開発とテスト専用)、Redis(AddStackExchangeRedisCache)、SQL Server(AddDistributedSqlServerCache)、Azure Cache for Redis、NCache などのサードパーティストアが含まれます。

HybridCache は、上記の 2 つのパターンを 1 つにまとめるために Microsoft が追加した抽象です。インプロセスの L1(デフォルトでは MemoryCache)を保持し、IDistributedCache を登録していれば、それを自動的に L2 として使います。GetOrCreateAsync の呼び出しは L1、次に L2 を確認し、それからファクトリを実行して両方の層に書き戻します。望まない限り、シリアライズに触れることはありません。

// .NET 11, C# 14
builder.Services.AddHybridCache();
// If an IDistributedCache is also registered, it becomes the L2 automatically.

public class ProductService(HybridCache cache, ProductDb db)
{
    public ValueTask<Product> GetAsync(int id, CancellationToken ct = default) =>
        cache.GetOrCreateAsync(
            $"product:{id}",
            async token => await db.LoadProductAsync(id, token),
            cancellationToken: ct);
}

IDistributedCache のブロックと同じ結果が、15 行ではなく 3 行で、キャッシュスタンピード保護と、自分で配線しなくてよい L1 層付きで得られます。

IMemoryCache を直接選ぶとき

直接行くことで受け入れる落とし穴: IMemoryCacheGetOrCreateAsync はスタンピードガードのない拡張メソッドです。コールドキャッシュのバースト下では、並行する各呼び出し元がファクトリを実行します。

IDistributedCache を直接選ぶとき

HybridCache を選ぶとき(デフォルト)

スタンピードのベンチマーク、具体的に

これは本番で表面化する違いなので、主張するのではなく測定する価値があります。200 ms のデータベース読み取りを模したファクトリを用意し、コールドキャッシュに対して同じキーで GetOrCreateAsync を 100 件並行で発行します。

// .NET 11, BenchmarkDotNet 0.15.x style harness (simplified)
async Task<int> Factory(CancellationToken _)
{
    Interlocked.Increment(ref _factoryCalls);
    await Task.Delay(200);          // stand-in for a DB / HTTP round trip
    return 42;
}

var tasks = Enumerable.Range(0, 100)
    .Select(_ => hybrid.GetOrCreateAsync("k", Factory).AsTask());
await Task.WhenAll(tasks);

HybridCache では _factoryCalls1 です。1 つの呼び出し元が 200 ms のファクトリを実行し、残りの 99 はその結果を待つため、バースト全体がバッキング呼び出し 1 回でおよそ 200 ms で片づきます。IMemoryCacheGetOrCreateAsync 拡張メソッドに切り替えると _factoryCalls は最大 100 まで上がります。コールドミスした呼び出し元を直列化するものが何もないからです。実際のデータベースに対しては、これはクエリ 1 回と、コネクションプールに 100 件積み上がる詰まりとの違いです。IMemoryCache の場合の正確な件数はタイミングによって変わります(最初の書き込み完了後に到達する呼び出し元もありえます)が、それこそが要点です。それは上限がなく非決定的であるのに対し、HybridCache はそれを 1 に固定します。数値は .NET 11(11.0.x)、Windows 11、組み込みの L1 のみで L2 未構成で測定しました。

有効期限: オプション名の違いが効いてくる

3 つの API は有効期限の名前が異なり、それらを取り違えるのが最も多い構成のバグです。

IMemoryCacheMemoryCacheEntryOptionsAbsoluteExpirationAbsoluteExpirationRelativeToNowSlidingExpiration を使います。IDistributedCacheDistributedCacheEntryOptions で同じ 3 つの名前を使います。HybridCacheHybridCacheEntryOptions で、意味の異なる 2 つのプロパティを使います。

// .NET 11, C# 14
var options = new HybridCacheEntryOptions
{
    Expiration = TimeSpan.FromMinutes(5),        // overall lifetime (drives L2)
    LocalCacheExpiration = TimeSpan.FromMinutes(1) // how long the L1 copy is trusted
};

Expiration はエントリの全体の有効期間で、L2 のコピーを支配します。LocalCacheExpiration は、エントリが L2 から再取得されるまで、インプロセスの L1 コピーがどれだけの間有効とみなされるかです。LocalCacheExpirationExpiration より短く設定することが、マルチサーバー配置で L1 の陳腐化を抑える方法です。各ノードはローカルコピーを最大 1 分間だけ信頼し、その後で共有 L2 に対して再検証します。HybridCache にスライディング有効期限の概念はありません。スライディングウィンドウに依存しているなら、それはより低レベルの API にとどまる理由になります。

知っておくべきその他のデフォルト: HybridCacheOptions.MaximumPayloadBytes のデフォルトは 1 MB、MaximumKeyLength は 1024 文字です。上限を超える値やキーはログに記録され、黙ってキャッシュされません。大きな blob をキャッシュする場合、これは静かな失敗モードになります。

あなたの代わりに判断を下す決め手

HybridCache におけるタグおよびキーの無効化は、他サーバーの L1 に届きません。RemoveByTagAsyncRemoveAsync を呼ぶと、エントリはローカルの L1 と共有 L2 から削除されますが、他の各ノードは、そのコピーが自身の LocalCacheExpiration で期限切れになるまで、自分の L1 コピーを返し続けます。ドキュメントは明示しています。タグの無効化は、将来の読み取りをミスとして扱う論理的な操作であり、他のノードを能動的にパージするわけではありません。

このたった 1 つの挙動が、いくつかの設計を決めます。

もう 1 つの決定要因は シリアライズの安全性 です。HybridCache は、IDistributedCache のスレッドセーフ保証を維持するため、デフォルトで呼び出し元ごとに新しいオブジェクトをデシリアライズします。キャッシュする型がイミュータブルなら、型を sealed にして [ImmutableObject(true)] を適用することでインスタンスの再利用を選択でき、呼び出しごとのデシリアライズのオーバーヘッドを取り除けます。キャッシュするオブジェクトがミュータブルで共有されているなら、その属性は適用しないでください。さもないと競合状態を持ち込みます。

推奨、あらためて

.NET 11 では、それをしない特定の理由がない限り、新しいキャッシュコードは HybridCache に対して書いてください。これは旧来の両 API のほぼドロップイン置換であり、IDistributedCache が強いる cache-aside のボイラープレートを取り除き、ガードのない IMemoryCache.GetOrCreateAsync が残すスタンピードの穴を塞ぎます。単一サーバーの速度、ゼロシリアライズ、または HybridCache が公開しない削除機能(サイズ上限、優先度、削除コールバック)が必要なときは、素の IMemoryCache に降りてください。L1 の陳腐化ウィンドウのない共有ストアが必要なとき、シリアライズされたバイトが別システムとの契約であるとき、あるいはキャッシュではなくセッションやキーの保存に使うときは、素の IDistributedCache に降りてください。その中間のすべて、つまりほとんどのキャッシュでは、HybridCache が答えです。

関連

出典

Comments

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

< 戻る