Redigera

Dela via


HybridCache library in ASP.NET Core

Important

HybridCache is currently still in preview but will be fully released after .NET 9.0 in a future minor release of .NET Extensions.

This article explains how to configure and use the HybridCache library in an ASP.NET Core app. For an introduction to the library, see the HybridCache section of the Caching overview.

Get the library

Install the Microsoft.Extensions.Caching.Hybrid package.

dotnet add package Microsoft.Extensions.Caching.Hybrid --version "9.0.0-preview.7.24406.2"

Register the service

Add the HybridCache service to the dependency injection (DI) container by calling AddHybridCache:

// Add services to the container.
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddAuthorization();

builder.Services.AddHybridCache();

The preceding code registers the HybridCache service with default options. The registration API can also configure options and serialization.

Get and store cache entries

The HybridCache service provides a GetOrCreateAsync method with two overloads, taking a key and:

  • A factory method.
  • State, and a factory method.

The method uses the key to try to retrieve the object from the primary cache. If the item isn't found in the primary cache (a cache miss), it then checks the secondary cache if one is configured. If it doesn't find the data there (another cache miss), it calls the factory method to get the object from the data source. It then stores the object in both primary and secondary caches. The factory method is never called if the object is found in the primary or secondary cache (a cache hit).

The HybridCache service ensures that only one concurrent caller for a given key calls the factory method, and all other callers wait for the result of that call. The CancellationToken passed to GetOrCreateAsync represents the combined cancellation of all concurrent callers.

The main GetOrCreateAsync overload

The stateless overload of GetOrCreateAsync is recommended for most scenarios. The code to call it is relatively simple. Here's an example:

public class SomeService(HybridCache cache)
{
    private HybridCache _cache = cache;

    public async Task<string> GetSomeInfoAsync(string name, int id, CancellationToken token = default)
    {
        return await _cache.GetOrCreateAsync(
            $"{name}-{id}", // Unique key to the cache entry
            async cancel => await GetDataFromTheSourceAsync(name, id, cancel),
            cancellationToken: token
        );
    }

    public async Task<string> GetDataFromTheSourceAsync(string name, int id, CancellationToken token)
    {
        string someInfo = $"someinfo-{name}-{id}";
        return someInfo;
    }
}

The alternative GetOrCreateAsync overload

The alternative overload might reduce some overhead from captured variables and per-instance callbacks, but at the expense of more complex code. For most scenarios the performance increase doesn't outweigh the code complexity. Here's an example that uses the alternative overload:

public class SomeService(HybridCache cache)
{
    private HybridCache _cache = cache;

    public async Task<string> GetSomeInfoAsync(string name, int id, CancellationToken token = default)
    {
        return await _cache.GetOrCreateAsync(
            $"{name}-{id}", // Unique key to the cache entry
            (name, id, obj: this),
            static async (state, token) =>
            await state.obj.GetDataFromTheSourceAsync(state.name, state.id, token),
            cancellationToken: token
        );
    }

    public async Task<string> GetDataFromTheSourceAsync(string name, int id, CancellationToken token)
    {
        string someInfo = $"someinfo-{name}-{id}";
        return someInfo;
    }
}

The SetAsync method

In many scenarios, GetOrCreateAsync is the only API needed. But HybridCache also has SetAsync to store an object in cache without trying to retrieve it first.

Remove cache entries by key

When the underlying data for a cache entry changes before it expires, remove the entry explicitly by calling RemoveAsync with the key to the entry. An overload lets you specify a collection of key values.

When an entry is removed, it is removed from both the primary and secondary caches.

Remove cache entries by tag

Tags can be used to group cache entries and invalidate them together.

Set tags when calling GetOrCreateAsync, as shown in the following example:

public class SomeService(HybridCache cache)
{
    private HybridCache _cache = cache;

    public async Task<string> GetSomeInfoAsync(string name, int id, CancellationToken token = default)
    {
        var tags = new List<string> { "tag1", "tag2", "tag3" };
        var entryOptions = new HybridCacheEntryOptions
        {
            Expiration = TimeSpan.FromMinutes(1),
            LocalCacheExpiration = TimeSpan.FromMinutes(1)
        };
        return await _cache.GetOrCreateAsync(
            $"{name}-{id}", // Unique key to the cache entry
            async cancel => await GetDataFromTheSourceAsync(name, id, cancel),
            entryOptions,
            tags,
            cancellationToken: token
        );
    }
    
    public async Task<string> GetDataFromTheSourceAsync(string name, int id, CancellationToken token)
    {
        string someInfo = $"someinfo-{name}-{id}";
        return someInfo;
    }
}

Remove all entries for a specified tag by calling RemoveByTagAsync with the tag value. An overload lets you specify a collection of tag values.

When an entry is removed, it is removed from both the primary and secondary caches.

Options

The AddHybridCache method can be used to configure global defaults. The following example shows how to configure some of the available options:

// Add services to the container.
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();

builder.Services.AddHybridCache(options =>
    {
        options.MaximumPayloadBytes = 1024 * 1024;
        options.MaximumKeyLength = 1024;
        options.DefaultEntryOptions = new HybridCacheEntryOptions
        {
            Expiration = TimeSpan.FromMinutes(5),
            LocalCacheExpiration = TimeSpan.FromMinutes(5)
        };
    });

The GetOrCreateAsync method can also take a HybridCacheEntryOptions object to override the global defaults for a specific cache entry. Here's an example:

public class SomeService(HybridCache cache)
{
    private HybridCache _cache = cache;

    public async Task<string> GetSomeInfoAsync(string name, int id, CancellationToken token = default)
    {
        var tags = new List<string> { "tag1", "tag2", "tag3" };
        var entryOptions = new HybridCacheEntryOptions
        {
            Expiration = TimeSpan.FromMinutes(1),
            LocalCacheExpiration = TimeSpan.FromMinutes(1)
        };
        return await _cache.GetOrCreateAsync(
            $"{name}-{id}", // Unique key to the cache entry
            async cancel => await GetDataFromTheSourceAsync(name, id, cancel),
            entryOptions,
            tags,
            cancellationToken: token
        );
    }
    
    public async Task<string> GetDataFromTheSourceAsync(string name, int id, CancellationToken token)
    {
        string someInfo = $"someinfo-{name}-{id}";
        return someInfo;
    }
}

For more information about the options, see the source code:

Limits

The following properties of HybridCacheOptions let you configure limits that apply to all cache entries:

  • MaximumPayloadBytes - Maximum size of a cache entry. Default value is 1 MB. Attempts to store values over this size are logged, and the value isn't stored in cache.
  • MaximumKeyLength - Maximum length of a cache key. Default value is 1024 characters. Attempts to store values over this size are logged, and the value isn't stored in cache.

Serialization

Use of a secondary, out-of-process cache requires serialization. Serialization is configured as part of registering the HybridCache service. Type-specific and general-purpose serializers can be configured via the AddSerializer and AddSerializerFactory methods, chained from the AddHybridCache call. By default, the library handles string and byte[] internally, and uses System.Text.Json for everything else. HybridCache can also use other serializers, such as protobuf or XML.

The following example configures the service to use a type-specific protobuf serializer:

// Add services to the container.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();

builder.Services.AddHybridCache(options =>
    {
        options.DefaultEntryOptions = new HybridCacheEntryOptions
        {
            Expiration = TimeSpan.FromSeconds(10),
            LocalCacheExpiration = TimeSpan.FromSeconds(5)
        };
    }).AddSerializer<SomeProtobufMessage, 
        GoogleProtobufSerializer<SomeProtobufMessage>>();

The following example configures the service to use a general-purpose protobuf serializer that can handle many protobuf types:

// Add services to the container.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromSeconds(10),
        LocalCacheExpiration = TimeSpan.FromSeconds(5)
    };
}).AddSerializerFactory<GoogleProtobufSerializerFactory>();

The secondary cache requires a data store, such as Redis or SqlServer. To use Azure Cache for Redis, for example:

  • Install the Microsoft.Extensions.Caching.StackExchangeRedis package.

  • Create an instance of Azure Cache for Redis.

  • Get a connection string that connects to the Redis instance. Find the connection string by selecting Show access keys on the Overview page in the Azure portal.

  • Store the connection string in the app's configuration. For example, use a user secrets file that looks like the following JSON, with the connection string in the ConnectionStrings section. Replace <the connection string> with the actual connection string:

    {
      "ConnectionStrings": {
        "RedisConnectionString": "<the connection string>"
      }
    }
    
  • Register in DI the IDistributedCache implementation that the Redis package provides. To do that, call AddStackExchangeRedisCache, and pass in the connection string. For example:

    builder.Services.AddStackExchangeRedisCache(options =>
    {
        options.Configuration = 
            builder.Configuration.GetConnectionString("RedisConnectionString");
    });
    
  • The Redis IDistributedCache implementation is now available from the app's DI container. HybridCache uses it as the secondary cache and uses the serializer configured for it.

For more information, see the HybridCache serialization sample app.

Cache storage

By default HybridCache uses MemoryCache for its primary cache storage. Cache entries are stored in-process, so each server has a separate cache that is lost whenever the server process is restarted. For secondary out-of-process storage, such as Redis or SQL Server, HybridCache uses the configured IDistributedCache implementation, if any. But even without an IDistributedCacheimplementation, the HybridCache service still provides in-process caching and stampede protection.

Optimize performance

To optimize performance, configure HybridCache to reuse objects and avoid byte[] allocations.

Reuse objects

By reusing instances, HybridCache can reduce the overhead of CPU and object allocations associated with per-call deserialization. This can lead to performance improvements in scenarios where the cached objects are large or accessed frequently.

In typical existing code that uses IDistributedCache, every retrieval of an object from the cache results in deserialization. This behavior means that each concurrent caller gets a separate instance of the object, which can't interact with other instances. The result is thread safety, as there's no risk of concurrent modifications to the same object instance.

Because much HybridCache usage will be adapted from existing IDistributedCache code, HybridCache preserves this behavior by default to avoid introducing concurrency bugs. However, objects are inherently thread-safe if:

  • They are immutable types.
  • The code doesn't modify them.

In such cases, inform HybridCache that it's safe to reuse instances by:

  • Marking the type as sealed. The sealed keyword in C# means that the class can't be inherited.
  • Applying the [ImmutableObject(true)] attribute to the type. The [ImmutableObject(true)] attribute indicates that the object's state can't be changed after it's created.

Avoid byte[] allocations

HybridCache also provides optional APIs for IDistributedCache implementations, to avoid byte[] allocations. This feature is implemented by the preview versions of the Microsoft.Extensions.Caching.StackExchangeRedis and Microsoft.Extensions.Caching.SqlServer packages. For more information, see IBufferDistributedCache Here are the .NET CLI commands to install the packages:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --prerelease
dotnet add package Microsoft.Extensions.Caching.SqlServer --prerelease

Custom HybridCache implementations

A concrete implementation of the HybridCache abstract class is included in the shared framework and is provided via dependency injection. But developers are welcome to provide custom implementations of the API.

Compatibility

The HybridCache library supports older .NET runtimes, down to .NET Framework 4.7.2 and .NET Standard 2.0.

Additional resources

For more information about HybridCache, see the following resources: