MemoryCache, DistributedCache and HybridCache

5/20/2024
5 minute read

The latest preview (.NET 9 preview 4) brought another caching structure to the .NET world - so let's order some things here.

What is a MemoryCache?

The MemoryCache is a datastructure that allows you, well, to cache objects in memory. It is a simple key-value store. A more in detailed blog post can be found here. "Caching in .NET with MemoryCache".

Here a simple example:

public async Task<IActionResult> GetBlogPost(int id)
{
    // Cache key
    var cacheKey = $"BlogPost_{id}";

    // Check if the cache contains the blog post
    if (!_memoryCache.TryGetValue(cacheKey, out BlogPost blogPost))
    {
        // Retrieve the blog post from the repository
        blogPost = await _blogRepository.GetBlogPostByIdAsync(id);

        // Save the blog post in the cache
        _memoryCache.Set(cacheKey, blogPost);
    }

    return Ok(blogPost);
}

This can be simplified to:

public async Task<IActionResult> GetBlogPost(int id)
{
    // Cache key
    var cacheKey = $"BlogPost_{id}";

    var blogPost = await _memoryCache.GetOrCreateAsync(cacheKey, async entry =>
    {
        return await _blogRepository.GetBlogPostByIdAsync(id);
    });

    return Ok(blogPost);
}

Of course there is a whole lot more to it - you can define how long your cache entry lives, with a multitude of strategies. A oversimplied version of the MemoryCache would be: ConcurrentDictionary<string, object>. Keep that in mind!

DistributedCache

The IDistributedCache is generally taken to communicate between multiple services (so multiple instances of an ASP.NET Web API Backend) and/or if you need to persist the data over the lifetime of your application (so after you shutdown and restarted your server). A famous example would be: Redis. So if you register something like this in your code:

builder.Services.AddStackExchangeRedisCache(...);

You register an implementation of IDistributedCache into your application. But you are also free to choose a database as your cache provider. For example an SqlServerCache:

builder.Services.AddDistributedSqlServerCache(options =>
{
    options.ConnectionString = builder.Configuration.GetConnectionString(
        "DistCache_ConnectionString");
    options.SchemaName = "dbo";
    options.TableName = "TestCache";
});

Okay - so far we know that the IMemoryCache lives and dies with the host application lifecycle (aka your app) and isn't well suited for distributed or load-balanced scenarios. Another fundemental difference between those two:

  • IMemoryCache - we will persist the live object in the same scope as the host lifetime!
  • IDistributedCache - we will have to serialize and deserialize the object (can be off box)!

That might make some scenarios difficult where you have non-serializble objects or objects that are expensive to serialize.

Now, look at the following code:

public class SomeService(IDistributedCache cache)
{
    public async Task<SomeInformation> GetSomeInformationAsync(string name, int id, CancellationToken token = default)
    {
        var key = $"someinfo:{name}:{id}"; // unique key for this combination
        var bytes = await cache.GetAsync(key, token); // try to get from cache
        SomeInformation info;
        if (bytes is null)
        {
            // cache miss; get the data from the real source
            info = await SomeExpensiveOperationAsync(name, id, token);

            // serialize and cache it
            bytes = SomeSerializer.Serialize(info);
            await cache.SetAsync(key, bytes, token);
        }
        else
        {
            // cache hit; deserialize it
            info = SomeSerializer.Deserialize<SomeInformation>(bytes);
        }
        return info;
    }

    // this is the work we're trying to cache
    private async Task<SomeInformation> SomeExpensiveOperationAsync(string name, int id,
        CancellationToken token = default)
    { /* ... */ }

    // ...
}

Source: https://github.com/dotnet/AspNetCore.Docs/issues/32361

The IDistributedCache doesn't have a nice API for simply putting and retrieving elements, giving you a chance to lots of things wrong. There is another problem: While SomeExpensiveOperationAsync might take some time, your GetSomeInformationAsync method is called multiple times. This can lead to Cache Stampede:

A cache stampede is a type of cascading failure that can occur when massively parallel computing systems with caching mechanisms come under a very high load. This behaviour is sometimes also called dog-piling

Source: https://en.wikipedia.org/wiki/Cache_stampede

So we call SomeExpensiveOperationAsync unneccessarily often and put our system under load, while waiting for the cache to populate might be the better strategy! Therefore meet:

HybridCache

That is where the new type HybridCache will come into play. Officially introduced with .NET 9 preview - but available (thanks to netstandard2.0) even for .NET Framework 4.7.2.

The call from above can be done like this:

public class SomeService(HybridCache cache)
{
    public async Task<SomeInformation> GetSomeInformationAsync(string name, int id, CancellationToken token = default)
    {
        return await cache.GetOrCreateAsync(
            $"someinfo:{name}:{id}", // unique key for this combination
            async cancel => await SomeExpensiveOperationAsync(name, id, cancel),
            token: token
        );
    }
}

That looks very much like the API of the IMemoryCache but behaves like the IDistrubtedCache "under the hood". Here are some other features, highlighted in the introduction:

As you might expect for parity with IDistributedCache, HybridCache supports explicit removal by key (cache.RemoveKeyAsync(...)). HybridCache also introduces new optional APIs for IDistributedCache implementations, to avoid byte[] allocations (this feature is implemented by the preview versions of Microsoft.Extensions.Caching.StackExchangeRedis and Microsoft.Extensions.Caching.SqlServer).

Serialization is configured as part of registering the service, with support for type-specific and generalized serializers via the .WithSerializer(...) and .WithSerializerFactory(...) methods, chained from the AddHybridCache(...) call. By default, the library handles string and byte[] internally, and uses System.Text.Json for everything else, but if you want to use protobuf, xml, or anything else: that's easy to do.

Source: https://github.com/dotnet/AspNetCore.Docs/issues/32361#issuecomment-2070480937

A very nice addition to reduce potential issues one can have with IDistributedCache.

An error has occurred. This application may no longer respond until reloaded. Reload x