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:
- 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.
- 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).
- 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.
- 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:
Set up the eviction policy to
volatile-lru
orvolatile-random
. See https://redis.io/topics/lru-cache#eviction-policies for details.Caution
Other eviction policies than
volatile-lru
orvolatile-random
are not supported.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.
With dependency injection
Follow these steps:
Add a reference to the Metalama.Patterns.Caching.Backends.Redis package.
Create a StackExchange.Redis.ConnectionMultiplexer and add it to the service collection as a singleton of the
IConnectionMultiplexer
interface type.23 // Add Redis. 24 builder.Services.AddSingleton<IConnectionMultiplexer>( 25 _ => 26 { 27 var redisConnectionOptions = new ConfigurationOptions(); 28 redisConnectionOptions.EndPoints.Add( endpoint.Address, endpoint.Port ); 29 30 return ConnectionMultiplexer.Connect( redisConnectionOptions ); 31 } );
Note
If you are using .NET Aspire, simply call
UseRedis()
.Go back to the code that initialized Metalama Caching by calling serviceCollection.AddMetalamaCaching. Call the WithBackend method, and supply a delegate that calls the Redis method.
Here is an example of the AddMetalamaCaching code.
35 // Add the caching service. 36 builder.Services.AddMetalamaCaching( 37 caching => caching.WithBackend( 38 backend => backend.Redis() ) );
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 IServiceProvider and call the InitializeAsync method.
49 // Initialize caching. 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.Patterns.Caching.Aspects;
2using System;
3
4namespace Doc.Redis;
5
6public 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}
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
9public 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 _cacheRegistration_Add = CachedMethodMetadata.Register(typeof(CloudCalculator).GetMethod("Add", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(int), typeof(int) }, null).ThrowIfMissing("CloudCalculator.Add(int, int)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, false);
39 }
40
41 public CloudCalculator(ICachingService? cachingService = null)
42 {
43 this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
44 }
45}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using System;
3using System.Threading.Tasks;
4
5namespace Doc.Redis;
6
7public sealed class ConsoleMain : IAsyncConsoleMain
8{
9 private readonly CloudCalculator _cloudCalculator;
10
11 public ConsoleMain( CloudCalculator cloudCalculator )
12 {
13 this._cloudCalculator = cloudCalculator;
14 }
15
16 public Task ExecuteAsync()
17 {
18 for ( var i = 0; i < 3; i++ )
19 {
20 var value = this._cloudCalculator.Add( 1, 1 );
21 Console.WriteLine( $"CloudCalculator returned {value}." );
22 }
23
24 Console.WriteLine(
25 $"In total, CloudCalculator performed {this._cloudCalculator.OperationCount} operation(s)." );
26
27 return Task.CompletedTask;
28 }
29}
Doing some very hard work. CloudCalculator returned 2. CloudCalculator returned 2. CloudCalculator returned 2. In total, CloudCalculator performed 1 operation(s).
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.Threading.Tasks;
9
10namespace Doc.Redis;
11
12internal static class Program
13{
14 public static async Task Main()
15 {
16 var builder = ConsoleApp.CreateBuilder();
17
18 // Add a local Redis server with a random-assigned port. You don't need this in your code.
19 using var redis = builder.Services.AddLocalRedisServer();
20 var endpoint = redis.Endpoint;
21
22 //
23 // Add Redis.
24 builder.Services.AddSingleton<IConnectionMultiplexer>(
25 _ =>
26 {
27 var redisConnectionOptions = new ConfigurationOptions();
28 redisConnectionOptions.EndPoints.Add( endpoint.Address, endpoint.Port );
29
30 return ConnectionMultiplexer.Connect( redisConnectionOptions );
31 } );
32 //
33
34 //
35 // Add the caching service.
36 builder.Services.AddMetalamaCaching(
37 caching => caching.WithBackend(
38 backend => backend.Redis() ) );
39 //
40
41 // Add other components as usual.
42 builder.Services.AddAsyncConsoleMain<ConsoleMain>();
43 builder.Services.AddSingleton<CloudCalculator>();
44
45 // Build the host.
46 await using var app = builder.Build();
47
48 //
49 // Initialize caching.
50 await app.Services.GetRequiredService<ICachingService>().InitializeAsync();
51 //
52
53 // Run the host.
54 await app.RunAsync();
55 }
56}
Without dependency injection
If you are not using dependency injection:
Call CachingService.Create, then WithBackend method, supply a delegate that calls the Redis method. Pass a RedisCachingBackendConfiguration and set the Connection property to your
ConnectionMultiplexer
.
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.AddMetalamaCaching, call the WithL1 method right after the Redis method.
The following snippet shows the updated AddMetalamaCaching code, with just a tiny change calling the WithL1 method.
36 // Add the caching service.
37 builder.Services.AddMetalamaCaching(
38 caching => caching.WithBackend(
39 backend => backend.Redis().WithL1() ) );
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.
Warning
Caching dependencies cannot be used on a Redis cluster. Only the master-replica topology is supported with caching dependencies. The cause of this limitation is that a cache operation with depedencies is implemented as a transaction of several operations, which must all reside on the same node.
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
13public 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 using var redis = appBuilder.Services.AddLocalRedisServer();
21 var endpoint = redis.Endpoint;
22
23 // Add the garbage collected service, implemented as IHostedService.
24 appBuilder.Services.AddRedisCacheDependencyGarbageCollector(
25 _ =>
26 {
27 // Build the Redis connection options.
28 var redisConnectionOptions = new ConfigurationOptions();
29 redisConnectionOptions.EndPoints.Add( endpoint.Address, endpoint.Port );
30
31 // The KeyPrefix must match _exactly_ the one used by the caching back-end.
32 var keyPrefix = "TheApp.1.0.0";
33
34 return new RedisCachingBackendConfiguration
35 {
36 NewConnectionOptions = redisConnectionOptions, KeyPrefix = keyPrefix
37 };
38 } );
39
40 var host = appBuilder.Build();
41
42 await host.StartAsync();
43
44 if ( args.Contains( "--full" ) )
45 {
46 Console.WriteLine( "Performing full collection." );
47
48 var collector =
49 host.Services.GetRequiredService<RedisCacheDependencyGarbageCollector>();
50
51 await collector.PerformFullCollectionAsync();
52 Console.WriteLine( "Full collection completed." );
53 }
54
55 const bool isProductionCode = false;
56
57 if ( isProductionCode )
58 {
59 // await host.WaitForShutdownAsync();
60 }
61 else
62 {
63 // This code is automatically executed so we shut down the service after 1 second.
64 await Task.Delay( 1000 );
65 await host.StopAsync();
66 }
67 }
68}