MetalamaConceptual documentationUsing Metalama PatternsCachingDistributed caching with Redis
Open sandboxFocusImprove this doc

Using Redis as a distributed server

If you have a distributed application where several instances run in parallel, Redis is an excellent choice for implementing caching due to the following reasons:

  1. In-Memory Storage: Redis stores its dataset in memory, allowing for very fast read and write operations, which are significantly faster than disk-based databases.
  2. Rich Data Structures and Atomic Operations: Redis is not just a simple key-value store; it supports multiple data structures like strings, hashes, lists, sets, sorted sets, and more. Combined with Redis's support for atomic operations on these complex data types, Metalama Caching can implement support for cache dependencies (see Working with cache dependencies).
  3. Scalability and Replication: Redis provides features for horizontal partitioning or sharding. As your dataset grows, you can distribute it across multiple Redis instances. Redis supports multi-instance replication, allowing for data redundancy and higher data availability. If the master fails, a replica can be promoted to master, ensuring that the system remains available.
  4. Pub/Sub: Thanks to the Redis Pub/Sub feature, Metalama can synchronize the distributed Redis cache with a local in-memory L1 cache. Metalama can also use this feature to synchronize several local in-memory caches without using Redis storage.

Our implementation uses the StackExchange.Redis library internally and is compatible with on-premises instances of Redis Cache as well as with the Azure Redis Cache cloud service.

When used with Redis, Metalama Caching supports the following features:

  • Distributed caching,
  • Non-blocking cache write operations,
  • In-memory L1 cache in front of the distributed L2 cache, and
  • Synchronization of several in-memory caches using Redis Pub/Sub.

This article covers all these topics.

Configuring the Redis server

The first step is to prepare your Redis server for use with Metalama caching. Follow these steps:

  1. Set up the eviction policy to volatile-lru or volatile-random. See https://redis.io/topics/lru-cache#eviction-policies for details.

    Caution

    Other eviction policies than volatile-lru or volatile-random are not supported.

  2. Set up the key-space notification to include the AKE events. See https://redis.io/topics/notifications#configuration for details.

Configuring the caching backend in Metalama

The second step is to configure Metalama Caching to use Redis. Follow these steps:

  1. Add a reference to the Metalama.Patterns.Caching.Backends.Redis package.

  2. Create an instance of StackExchange.Redis.ConnectionMultiplexer.

  3. Go back to the code that initialized Metalama Caching by calling serviceCollection.AddCaching or CachingService.Create. Call the WithBackend method, and supply a delegate that calls the Memory method, then immediately call the WithAzureSynchronization method. Pass the topic connection string as a parameter.

    Here is an example of the AddCaching code.

    23            builder.Services.AddCaching(
    24                caching => caching.WithBackend(
    25                    backend =>
    26                    {
    27                        // Get the random port of the test Redis server. You don't need this in your code.
    28                        var redisServer = caching.ServiceProvider!.GetRequiredService<LocalRedisServer>();
    29
    30                        // Build the Redis connection options.
    31                        var redisConnectionOptions = new ConfigurationOptions();
    32                        redisConnectionOptions.EndPoints.Add( "localhost", redisServer.Port );
    33
    34                        // Build the Redis caching options. As a best practice, assign a version-specific KeyPrefix.
    35                        var thisAssembly = Assembly.GetCallingAssembly().GetName();
    36                        var keyPrefix = $"{thisAssembly.Name}.{thisAssembly.Version}";
    37
    38                        // Finally, build the Redis caching back-end.
    39                        return backend.Redis( new RedisCachingBackendConfiguration( redisConnectionOptions, keyPrefix ) );
    40                    } ) );
  4. We recommend initializing the caching service during the initialization sequence of your application, otherwise the service will be initialized lazily upon first use. Get the ICachingService interface from the <xref:System.IServiceProvider> and call the InitializeAsync method.

    50            await app.Services.GetRequiredService<ICachingService>().InitializeAsync();

Example: Caching Using Redis

Here is an update of the example used in Getting started with Metalama Caching, modified to use Redis instead of MemoryCache as the caching back-end.

1using Metalama.Documentation.Helpers.ConsoleApp;
2using Microsoft.Extensions.Hosting;
3using System;
4using System.Threading.Tasks;
5
6namespace Doc.Redis
7{
8    public sealed class ConsoleMain : IAsyncConsoleMain
9    {
10        private readonly CloudCalculator _cloudCalculator;
11
12        public ConsoleMain( CloudCalculator cloudCalculator )
13        {
14            this._cloudCalculator = cloudCalculator;
15        }
16
17        public Task ExecuteAsync()
18        {
19            for ( var i = 0; i < 3; i++ )
20            {
21                var value = this._cloudCalculator.Add( 1, 1 );
22                Console.WriteLine( $"CloudCalculator returned {value}." );
23            }
24
25            Console.WriteLine( $"In total, CloudCalculator performed {this._cloudCalculator.OperationCount} operation(s)." );
26
27            return Task.CompletedTask;
28        }
29    }
30}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Metalama.Documentation.Helpers.Redis;
3using Metalama.Patterns.Caching;
4using Metalama.Patterns.Caching.Backends.Redis;
5using Metalama.Patterns.Caching.Building;
6using Microsoft.Extensions.DependencyInjection;
7using StackExchange.Redis;
8using System.Reflection;
9using System.Threading.Tasks;
10
11namespace Doc.Redis
12{
13    internal static class Program
14    {
15        public static async Task Main()
16        {
17            var builder = ConsoleApp.CreateBuilder();
18
19            // Add a local Redis server with a random-assigned port. You don't need this in your code.
20            builder.Services.AddLocalRedisServer();
21
22            // Add the caching service.                         
23            builder.Services.AddCaching(
24                caching => caching.WithBackend(
25                    backend =>
26                    {
27                        // Get the random port of the test Redis server. You don't need this in your code.
28                        var redisServer = caching.ServiceProvider!.GetRequiredService<LocalRedisServer>();
29
30                        // Build the Redis connection options.
31                        var redisConnectionOptions = new ConfigurationOptions();
32                        redisConnectionOptions.EndPoints.Add( "localhost", redisServer.Port );
33
34                        // Build the Redis caching options. As a best practice, assign a version-specific KeyPrefix.
35                        var thisAssembly = Assembly.GetCallingAssembly().GetName();
36                        var keyPrefix = $"{thisAssembly.Name}.{thisAssembly.Version}";
37
38                        // Finally, build the Redis caching back-end.
39                        return backend.Redis( new RedisCachingBackendConfiguration( redisConnectionOptions, keyPrefix ) );
40                    } ) );
41
42            // Add other components as usual.
43            builder.Services.AddAsyncConsoleMain<ConsoleMain>();
44            builder.Services.AddSingleton<CloudCalculator>();
45
46            // Build the host.
47            await using var app = builder.Build();
48
49            // Initialize caching.
50            await app.Services.GetRequiredService<ICachingService>().InitializeAsync();
51
52
53            // Run the host.
54            await app.RunAsync();
55        }
56    }
57}
Source Code
1using Metalama.Patterns.Caching.Aspects;
2using System;

3

4namespace Doc.Redis

5{
6    public sealed class CloudCalculator
7    {
8        public int OperationCount { get; private set; }
9
10        [Cache]
11        public int Add( int a, int b )
12        {
13            Console.WriteLine( "Doing some very hard work." );
14










15            this.OperationCount++;
16
17            return a + b;
18        }
19    }















20}
Transformed Code
1using Metalama.Patterns.Caching;
2using Metalama.Patterns.Caching.Aspects;
3using Metalama.Patterns.Caching.Aspects.Helpers;
4using System;
5using System.Reflection;
6
7namespace Doc.Redis
8{
9    public sealed class CloudCalculator
10    {
11        public int OperationCount { get; private set; }
12
13        [Cache]
14        public int Add(int a, int b)
15        {
16            static object? Invoke(object? instance, object?[] args)
17            {
18                return ((CloudCalculator)instance).Add_Source((int)args[0], (int)args[1]);
19            }
20
21            return _cachingService!.GetFromCacheOrExecute<int>(_cacheRegistration_Add!, this, new object[] { a, b }, Invoke);
22        }
23
24        private int Add_Source(int a, int b)
25        {
26            Console.WriteLine("Doing some very hard work.");
27
28            this.OperationCount++;
29
30            return a + b;
31        }
32
33        private static readonly CachedMethodMetadata _cacheRegistration_Add;
34        private ICachingService _cachingService;
35
36        static CloudCalculator
37        ()
38        {
39            CloudCalculator._cacheRegistration_Add = CachedMethodMetadata.Register(RunTimeHelpers.ThrowIfMissing(typeof(CloudCalculator).GetMethod("Add", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(int), typeof(int) }, null)!, "CloudCalculator.Add(int, int)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, false);
40        }
41
42        public CloudCalculator
43        (ICachingService? cachingService = default)
44        {
45            this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
46        }
47    }
48}
Doing some very hard work.
CloudCalculator returned 2.
CloudCalculator returned 2.
CloudCalculator returned 2.
In total, CloudCalculator performed 1 operation(s).

Adding a local in-memory cache in front of your Redis cache

For higher performance, you can add an additional, in-process layer of caching (called L1) between your application and the remote Redis server (called L2).

The benefit of using an in-memory L1 cache is to decrease latency between the application and the Redis server, and to decrease CPU load due to the deserialization of objects. To further decrease latency, write operations to the L2 cache are performed in the background.

To enable the local cache, inside serviceCollection.AddCaching, call the WithL1 method right after the Redis method.

The following snippet shows the updated AddCaching code, with just a tiny change calling the WithL1 method.

23            builder.Services.AddCaching(
24                caching => caching.WithBackend(
25                    backend =>
26                    {
27                        // Get the random port of the test Redis server. You don't need this in your code.
28                        var redisServer = caching.ServiceProvider!.GetRequiredService<LocalRedisServer>();
29
30                        // Build the Redis connection options.
31                        var redisConnectionOptions = new ConfigurationOptions();
32                        redisConnectionOptions.EndPoints.Add( "localhost", redisServer.Port );
33
34                        // Build the Redis caching options. As a best practice, assign a version-specific KeyPrefix.
35                        var thisAssembly = Assembly.GetCallingAssembly().GetName();
36                        var keyPrefix = $"{thisAssembly.Name}.{thisAssembly.Version}";
37
38                        // Finally, build the Redis caching back-end. Add an L1 cache.
39                        return backend.Redis( new RedisCachingBackendConfiguration( redisConnectionOptions, keyPrefix ) ).WithL1();
40                    } ) );

When you run several nodes of your applications with the same Redis server and the same KeyPrefix, the L1 caches of each application node are synchronized using Redis notifications.

Warning

Due to the asynchronous nature of notification-based invalidation, there may be a few milliseconds during which different application nodes may see different values of cache items. However, the application instance initiating the change will have a consistent view of the cache. Short lapses of inconsistencies are generally harmless if the application clients are affinitized to one application node because each application instance has a consistent view. However, if application clients are not affinitized, they may experience cache consistency issues, and the developers who maintain it may lose a few hairs in the troubleshooting process.

Using dependencies with the Redis caching backend

Metalama Caching's Redis back-end supports dependencies (see Working with cache dependencies), but this feature is disabled by default with the Redis caching backend due to its significant performance and deployment impact:

  • From a performance perspective, the cache dependencies need to be stored in Redis (therefore consuming memory) and handled in a transactional way (therefore consuming processing power).
  • From a deployment perspective, the server requires a garbage collection service to run continuously, even when the app is not running. This service cleans up dependencies when cache items are expired from the cache.

If you choose to enable dependencies with Redis, you need to ensure that at least one instance of the cache GC process is running. It is legal to have several instances of this process running, but since all instances will compete to process the same messages, it is better to ensure that only a small number of instances (ideally one) is running.

To enable dependencies, set the RedisCachingBackendConfiguration.SupportsDependencies property to true when initializing the Redis caching back-end.

Running the dependency GC process

The recommended approach to run the dependency GC process is to create an application host using the Microsoft.Extensions.Hosting namespace. The GC process implements the IHostedService interface. To add it to the application, use the AddRedisCacheDependencyGarbageCollector extension method.

In case of an outage of the service running the GC process, execute the PerformFullCollectionAsync method.

The following program demonstrates this:

1using Metalama.Documentation.Helpers.Redis;
2using Metalama.Patterns.Caching.Backends.Redis;
3using Microsoft.Extensions.DependencyInjection;
4using Microsoft.Extensions.Hosting;
5using StackExchange.Redis;
6using System;
7using System.Linq;
8using System.Threading.Tasks;
9
10
11namespace Doc.RedisGC
12{
13    public sealed class Program
14    {
15        public static async Task Main( string[] args )
16        {
17            var appBuilder = Host.CreateApplicationBuilder();
18
19            // Add a local Redis server with a random-assigned port. You don't need this in your code.
20            appBuilder.Services.AddLocalRedisServer();
21
22            // Add the garbage collected service, implemented as IHostedService.
23            appBuilder.Services.AddRedisCacheDependencyGarbageCollector(
24                serviceProvider =>
25                {
26                    // Get the random port of the test Redis server. You don't need this in your code.
27                    var redisServer = serviceProvider.GetRequiredService<LocalRedisServer>();
28
29                    // Build the Redis connection options.
30                    var redisConnectionOptions = new ConfigurationOptions();
31                    redisConnectionOptions.EndPoints.Add( "localhost", redisServer.Port );
32
33                    // The KeyPrefix must match _exactly_ the one used by the caching back-end.
34                    var keyPrefix = "TheApp.1.0.0";
35
36                    return new RedisCachingBackendConfiguration( redisConnectionOptions, keyPrefix );
37                } );
38
39            var host = appBuilder.Build();
40
41            await host.StartAsync();
42
43            if ( args.Contains( "--full" ) )
44            {
45                Console.WriteLine( "Performing full collection." );
46                var collector = host.Services.GetRequiredService<RedisCacheDependencyGarbageCollector>();
47                await collector.PerformFullCollectionAsync();
48                Console.WriteLine( "Full collection completed." );
49            }
50
51            const bool isProductionCode = false;
52
53            if ( isProductionCode )
54            {
55                // await host.WaitForShutdownAsync();
56            }
57            else
58            {
59                // This code is automatically executed so we shut down the service after 1 second.
60                await Task.Delay( 1000 );
61                await host.StopAsync();
62            }
63        }
64    }
65}