How to implement the Cache-Aside pattern using Redis in C# .NET?

Asked 1 month ago Updated 15 days ago 161 views

1

The Cache-Aside pattern (also known as lazy loading) ensures application code reads data from cache first. If a cache miss occurs, data is retrieved from the primary database and written back into Redis for future requests.

C# .NET Implementation Example

Here is an example implementing this caching strategy using the official StackExchange.Redis library in .NET.

using StackExchange.Redis;
using System.Text.Json;

public class CacheService
{
    private readonly IDatabase _db;

    public CacheService(IConnectionMultiplexer redis)
    {
        // Retrieve database instance from multiplexer
        _db = redis.GetDatabase();
    }

    public async Task GetUserAsync(int userId)
    {
        string key = $"user:{userId}";
        // Attempt fetching item string from Redis cache
        string cachedValue = await _db.StringGetAsync(key);

        if (!string.IsNullOrEmpty(cachedValue))
        {
            // Deserialize cached JSON back to target object
            return JsonSerializer.Deserialize(cachedValue);
        }

        // Fallback to database query on cache miss
        UserDto dbUser = await FetchUserFromDbAsync(userId);
        if (dbUser != null)
        {
            // Store result into Redis with 30-minute expiration
            string json = JsonSerializer.Serialize(dbUser);
            await _db.StringSetAsync(key, json, TimeSpan.FromMinutes(30));
        }
        return dbUser;
    }

    private async Task FetchUserFromDbAsync(int id)
    {
        // Simulated database call
        return await Task.FromResult(new UserDto { Id = id, Name = "John Doe" });
    }
}

public class UserDto
{
    public int Id { get; set; }
    public string Name { get; set; }
}

2 Answers


0

The cache-aside pattern keeps the database as the source of truth and uses Redis to speed up reads. The application checks Redis first; on a cache miss, it loads the value from the database, stores it in Redis, and returns it.

With StackExchange.Redis, inject an IDatabase into the service that reads your data. This example caches a product for five minutes:

using System.Text.Json;
using StackExchange.Redis;

public sealed record Product(int Id, string Name, decimal Price);

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task UpdateAsync(Product product, CancellationToken cancellationToken);
}

public sealed class ProductService
{
    private readonly IDatabase _cache;
    private readonly IProductRepository _repository;

    public ProductService(IDatabase cache, IProductRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async Task<Product?> GetAsync(int id, CancellationToken cancellationToken)
    {
        var key = $"product:{id}";
        var cached = await _cache.StringGetAsync(key);

        // Return a valid cached value before querying the database.
        if (cached.HasValue)
        {
            try
            {
                var product = JsonSerializer.Deserialize<Product>((string)cached);
                if (product is not null)
                    return product;
            }
            catch (JsonException)
            {
                // Treat malformed cache data as a miss and reload it below.
            }
        }

        var fromDatabase = await _repository.GetByIdAsync(id, cancellationToken);
        if (fromDatabase is null)
            return null;

        // Store the database result with a finite lifetime.
        var json = JsonSerializer.Serialize(fromDatabase);
        await _cache.StringSetAsync(key, json, TimeSpan.FromMinutes(5));

        return fromDatabase;
    }

    public async Task UpdateAsync(Product product, CancellationToken cancellationToken)
    {
        // Persist first so the database remains the source of truth.
        await _repository.UpdateAsync(product, cancellationToken);

        // Remove the old value so the next read reloads the updated product.
        await _cache.KeyDeleteAsync($"product:{product.Id}");
    }
}

Register one Redis connection for the application and obtain its database from that shared connection; avoid opening a new connection for each request. For example, with dependency injection you can register IConnectionMultiplexer as a singleton and provide GetDatabase() to this service.

The expiry limits how long an untouched entry can remain in Redis. The explicit cache invalidation after an update prevents ordinary reads from serving an old value. If several requests miss the same key at once, they may all query the database—a cache stampede. For high-traffic keys, consider request coalescing or a short-lived per-key lock. This example also assumes Redis is available; decide whether your application should fail the read or fall back to the database if Redis itself is unavailable.

1

Introduction to the Cache-Aside Pattern

The Cache-Aside pattern, also known as Lazy Loading, is one of the most common strategies for integrating a cache with a database. In this approach, the application code is responsible for checking the cache before querying the database. If the data is missing (a cache miss), the application fetches it from the persistent store, populates the cache, and then returns it to the caller. This pattern keeps the application logic explicit and gives fine-grained control over cache invalidation.

Prerequisites

To follow this guide, you will need a running instance of Redis and the StackExchange.Redis NuGet package added to your C# .NET project.

Step-by-Step Implementation

Below is a complete service class that demonstrates how to read and write data using the Cache-Aside pattern. The class uses a ConnectionMultiplexer to manage the connection to Redis and encapsulates the logic for fetching user profiles.

Caching a User Profile by ID

When a request arrives, the service first attempts to retrieve the user from Redis. If the entry is absent, it queries the database (simulated here with an in-memory dictionary), writes the result back to Redis with a sliding expiration, and finally returns the data.

// Establish a connection to the local Redis instance.
ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("localhost");
IDatabase cache = redis.GetDatabase();

// In-memory dictionary simulating a persistent database.
Dictionary<int, UserProfile> database = new()
{
    { 1, new UserProfile { Id = 1, Name = "Alice" } },
    { 2, new UserProfile { Id = 2, Name = "Bob" } }
};

public UserProfile? GetUserById(int userId)
{
    // Construct the cache key using a consistent naming convention.
    string cacheKey = $"user:{userId}";

    // Attempt to retrieve the serialized object from Redis.
    // Returns null if the key does not exist (cache miss).
    string? cachedValue = cache.StringGet(cacheKey);

    if (!string.IsNullOrEmpty(cachedValue))
    {
        // Deserialize the JSON string back into a UserProfile object.
        return JsonSerializer.Deserialize<UserProfile>(cachedValue);
    }

    // Cache miss: query the underlying database.
    if (database.TryGetValue(userId, out UserProfile? user))
    {
        // Serialize the retrieved object to JSON for storage.
        string serializedUser = JsonSerializer.Serialize(user);

        // Store the serialized object in Redis with a 5-minute sliding expiration.
        cache.StringSet(cacheKey, serializedUser, TimeSpan.FromMinutes(5));

        // Return the freshly fetched user to the caller.
        return user;
    }

    // Return null if the user does not exist in the database.
    return null;
}

Invalidating the Cache on Updates

Whenever the underlying data changes, you must explicitly remove the corresponding cache entry to prevent serving stale data. The following method demonstrates how to update a user profile and purge the stale cache record.

// Update the user in the persistent store.
public bool UpdateUser(int userId, UserProfile updatedUser)
{
    if (!database.ContainsKey(userId))
    {
        return false; // User not found.
    }

    database[userId] = updatedUser;

    // Remove the stale cache entry to ensure the next read fetches fresh data.
    string cacheKey = $"user:{userId}";
    cache.KeyDelete(cacheKey);

    return true;
}

By following this pattern, you ensure that your C# .NET application leverages Redis for high-performance reads while maintaining data consistency through explicit cache invalidation on writes. Remember to handle connection resilience and serialization overhead in production environments.

Write Your Answer