Loading...

Cache with .NET (Hybrid Cache)


This blog post, the fourth part of the "Caching in .NET" series, explains the Hybrid Cache strategy, which combines the benefits of both in-memory and distributed caching. The goal is to maximize performance while ensuring data consistency in distributed environments. The article provides a simple example of how to implement this approach in an ASP.NET Core Web API by creating a custom HybridCacheService.

Using Hybrid Cache in .NET: Balancing Performance and Consistency (Cache Series Part 4)

Performance and scalability are the cornerstones of modern software architectures. In previous parts of our Caching in .NET series, we've discussed ultra-fast access with in-memory caching and data consistency across multiple servers with distributed caches like Redis. What if there was a solution that combined the best of both worlds? This is precisely where Hybrid Cache comes in!

In this blog post, we'll explain what Hybrid Cache is, why it's an indispensable solution for applications seeking both speed and consistency, its benefits, common use cases, and provide a simple example of how you can implement this powerful technique, especially in your ASP.NET Core Web API applications.

What is Hybrid Cache?

A Hybrid Cache is a multi-layered caching strategy that combines a local (in-memory) cache with a distributed cache. The core idea is to keep the most frequently accessed data in the application server's own memory (in-memory) while storing less frequently accessed data, or data that needs to be consistent across all application instances, in a distributed cache (e.g., Redis, Couchbase).

When data is requested, the process is as follows:

  1. Local Cache Check: First, it checks if the data is present in the application server's own in-memory cache. This is the fastest access layer.

  2. Distributed Cache Check: If the data is not found in the local cache, the distributed cache is queried.

  3. Data Source Check: If the data is still not found in the distributed cache, it is fetched from the actual data source (e.g., database or external API).

  4. Caching and Synchronization: The fetched data is written to both the distributed cache and the local cache. Furthermore, if the data changes in the primary source, local caches can be invalidated or updated via the distributed cache (e.g., using Pub/Sub mechanisms).

Why Should You Use Hybrid Cache? Benefits and Necessity

Hybrid Cache offers significant advantages by addressing the limitations of using in-memory or distributed cache alone:

  • Maximized Performance: Since most data is kept in local memory, read operations are nearly instantaneous. This dramatically improves application response times.

  • Data Consistency: Thanks to the distributed caching layer, data consistency is maintained across multiple instances of the application. When one server updates the cache, other servers' caches can be appropriately invalidated or updated.

  • Reduced Network and Database Load: The local cache reduces unnecessary network calls to the distributed cache. The distributed cache, in turn, minimizes calls to the database, lowering both network traffic and the load on the underlying data source.

  • Enhanced Scalability: It facilitates horizontal scaling by making the application layer consume fewer resources and respond faster.

  • Improved Resilience: Even if there's a temporary outage in the distributed cache, data in the local cache can continue to be served for some time.

Common Hybrid Cache Use Cases

The Hybrid Cache strategy particularly shines in the following scenarios:

  • High-Traffic Websites and APIs: Situations where frequently accessed static or semi-static content (product catalogs, news articles, user profiles) needs to be served extremely fast.

  • Microservices Architectures: Data shared among microservices, where each service also needs its own fast-access local copy.

  • Read-Heavy Applications: Systems where read operations vastly outnumber write operations, but the data needs to be relatively fresh.

  • Dynamic Configuration Data: Application-wide settings that can change at runtime but don't need to be fetched from the database on every request.

Using Hybrid Cache with ASP.NET Core Web API

While there isn't a direct “Hybrid Cache” class in ASP.NET Core, you can easily build your own hybrid solution by combining IMemoryCache and IDistributedCache. This typically involves writing a custom caching service that leverages both caches internally.

Here's a simple example demonstrating how to create a Hybrid Cache approach by combining IMemoryCache and IDistributedCache:

1. Install NuGet Packages:

Include the necessary packages for both in-memory and a distributed cache (e.g., for Redis):

Install-Package Microsoft.Extensions.Caching.Memory
Install-Package Microsoft.Extensions.Caching.StackExchangeRedis

2. Create a Custom Cache Service:

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Text.Json;
using System.Threading.Tasks;

namespace MyWebApp.Services
{
    public interface IHybridCacheService
    {
        Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null);
        Task RemoveAsync(string key);
    }

    public class HybridCacheService : IHybridCacheService
    {
        private readonly IMemoryCache _memoryCache;
        private readonly IDistributedCache _distributedCache;
        private readonly DistributedCacheEntryOptions _defaultDistributedOptions;
        private readonly MemoryCacheEntryOptions _defaultMemoryOptions;


        public HybridCacheService(IMemoryCache memoryCache, IDistributedCache distributedCache)
        {
            _memoryCache = memoryCache;
            _distributedCache = distributedCache;

            // Define default options (optional)
            _defaultDistributedOptions = new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
                SlidingExpiration = TimeSpan.FromMinutes(2)
            };
            _defaultMemoryOptions = new MemoryCacheEntryOptions()
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
                SlidingExpiration = TimeSpan.FromMinutes(1)
            };
        }

        public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null)
        {
            // 1. Read from Local (In-Memory) Cache
            if (_memoryCache.TryGetValue(key, out T item))
            {
                return item;
            }

            // 2. Read from Distributed Cache
            var distributedCacheJson = await _distributedCache.GetStringAsync(key);
            if (!string.IsNullOrEmpty(distributedCacheJson))
            {
                item = JsonSerializer.Deserialize<T>(distributedCacheJson);
                // Add data read from distributed cache to local cache as well
                _memoryCache.Set(key, item, _defaultMemoryOptions);
                return item;
            }

            // 3. Fetch from Data Source
            item = await factory();
            
            // Write data to both distributed and local cache
            var jsonToCache = JsonSerializer.Serialize(item);
            var distributedOptions = _defaultDistributedOptions;
            if (absoluteExpirationRelativeToNow.HasValue || slidingExpiration.HasValue)
            {
                distributedOptions = new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow ?? _defaultDistributedOptions.AbsoluteExpirationRelativeToNow,
                    SlidingExpiration = slidingExpiration ?? _defaultDistributedOptions.SlidingExpiration
                };
            }

            await _distributedCache.SetStringAsync(key, jsonToCache, distributedOptions);
            _memoryCache.Set(key, item, _defaultMemoryOptions);

            return item;
        }

        public async Task RemoveAsync(string key)
        {
            _memoryCache.Remove(key); // Remove from local cache
            await _distributedCache.RemoveAsync(key); // Remove from distributed cache
            // Note: In a real system, you'd need a Pub/Sub or similar mechanism
            // to invalidate local caches on other application instances.
        }
    }
}

3. Service Configuration (Program.cs):

Configure the distributed cache service and your HybridCacheService in your Program.cs file. (This example uses Redis; for Couchbase, you would use AddCouchbaseDistributedCache.)

using MyWebApp.Services; // For HybridCacheService
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.StackExchangeRedis; // For Redis
using System;
using System.Text.Json;
using System.Threading.Tasks;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Add in-memory cache service
builder.Services.AddMemoryCache();

// Add distributed cache service (e.g., Redis)
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("RedisConnection");
    options.InstanceName = "HybridCacheSample_";
});

// Add our custom HybridCacheService to dependency injection
builder.Services.AddScoped<IHybridCacheService, HybridCacheService>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

4. Add Redis Connection String to appsettings.json:

{
  "ConnectionStrings": {
    "RedisConnection": "localhost:6379,abortConnect=false"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

5. Cache Usage (Example Web API Controller):

Inject IHybridCacheService to cache product data.

using Microsoft.AspNetCore.Mvc;
using MyWebApp.Services;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MyWebApp.Controllers
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public string Category { get; set; }
    }

    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private readonly IHybridCacheService _hybridCacheService;

        public ProductsController(IHybridCacheService hybridCacheService)
        {
            _hybridCacheService = hybridCacheService;
        }

        [HttpGet("get-products-hybrid")]
        public async Task<IActionResult> GetProductsHybrid()
        {
            string cacheKey = "AllProductsHybrid";

            // Get data from hybrid cache, or fetch from source and cache
            var products = await _hybridCacheService.GetOrCreateAsync(
                cacheKey,
                async () =>
                {
                    // This part would normally be a database or external API call.
                    // Simulating with a 3-second delay.
                    await Task.Delay(3000);
                    return new List<Product>
                    {
                        new Product { Id = 1, Name = "Laptop Pro", Price = 2500, Category = "Electronics" },
                        new Product { Id = 2, Name = "Gaming Mouse", Price = 75, Category = "Peripherals" },
                        new Product { Id = 3, Name = "Mechanical Keyboard", Price = 120, Category = "Peripherals" }
                    };
                },
                absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(10), // Max duration for distributed and local
                slidingExpiration: TimeSpan.FromMinutes(2) // Sliding duration for distributed and local
            );

            return Ok(new { Source = "Hybrid Cache Logic", Data = products });
        }

        [HttpGet("clear-products-hybrid-cache")]
        public async Task<IActionResult> ClearProductsHybridCache()
        {
            string cacheKey = "AllProductsHybrid";
            await _hybridCacheService.RemoveAsync(cacheKey);
            return Ok("AllProductsHybrid cache cleared from both local and distributed layers (Pub/Sub needed for other servers).");
        }
    }
}

Conclusion

Hybrid Cache is a powerful strategy for providing your .NET applications with both maximum performance (thanks to local cache) and data consistency (thanks to distributed cache). In high-traffic and distributed environments, it can significantly improve user experience by reducing expensive data source calls and enhancing response times. You can integrate this power into your ASP.NET Core applications by creating your own HybridCacheService or by using existing hybrid caching libraries.

This blog post constituted the third part of our Caching in .NET series. We strongly recommend reading the previous parts of the series (In-Memory and Distributed Cache) and experiencing these powerful caching strategies in your own projects. In future parts, we will cover more advanced caching topics!