Distributed Caching and Redis

An in-memory cache is private to one replica; IDistributedCache puts entries in a shared store such as Redis, as bytes.

SyntaxTask SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)

In production an app usually runs as several copies of the same program at once. Each copy is a replica. A cache held in one replica's memory is invisible to the others. Ten replicas mean ten separate caches: ten first-time misses, ten copies of the same value, and an entry you delete from one still being served by the other nine.

IDistributedCache is the interface for a cache that lives outside the process, in a store every replica can reach. Redis is the usual store: a separate server that holds keys and values in memory. Register it with builder.Services.AddStackExchangeRedisCache(...) from the Microsoft.Extensions.Caching.StackExchangeRedis package, then take an IDistributedCache as a constructor parameter. AddDistributedMemoryCache() offers the same interface backed by local memory; it is useful on a development machine and it is not a distributed cache.

Everything is bytes

The interface stores a byte[] against a string key, because bytes are all a network store can hold. Turning an object into bytes is serialising it, and JsonSerializer.SerializeToUtf8Bytes is the common choice. SetStringAsync and GetStringAsync are shorthand for encoding a string as UTF-8 bytes yourself. Expiry is set per entry with DistributedCacheEntryOptions, which has the same AbsoluteExpirationRelativeToNow and SlidingExpiration you met in memory caching.

What it does not do

There is no GetOrCreate. Look up, miss, load, store: you write all four, and nothing stops two replicas loading the same key at the same moment. Every call is a network round trip, so it is slower than memory and it can fail. HybridCache exists to cover both gaps.

Example

Example · csharp
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;

// AddDistributedMemoryCache stands in for Redis here: same interface, one process.
// Packages: Microsoft.Extensions.Caching.Memory, Microsoft.Extensions.DependencyInjection.
ServiceCollection services = new ServiceCollection();
services.AddDistributedMemoryCache();
IDistributedCache cache = services.BuildServiceProvider().GetRequiredService<IDistributedCache>();

// The cache holds bytes, so the object becomes UTF-8 JSON first.
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new Product("A-1", "Blue mug"));
Console.WriteLine(Encoding.UTF8.GetString(payload));

DistributedCacheEntryOptions options = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
};

await cache.SetAsync("product:A-1", payload, options);

byte[]? found = await cache.GetAsync("product:A-1");
Product stored = JsonSerializer.Deserialize<Product>(found!)!;
Console.WriteLine($"{stored.Sku}: {stored.Name}");

// A miss returns null. There is no TryGetValue here.
byte[]? missing = await cache.GetAsync("product:B-2");
Console.WriteLine(missing is null ? "B-2: miss" : "B-2: hit");

record Product(string Sku, string Name);

// Output:
// {"Sku":"A-1","Name":"Blue mug"}
// A-1: Blue mug
// B-2: miss

When to use it

  • Share one cached copy of an exchange-rate feed across every replica of a service.
  • Keep a sign-in session or basket reachable when the next request lands on a different pod.
  • Cut calls to a rate-limited third-party API that all replicas use.
  • Keep cached data alive through a deployment, when every process is restarted.

More examples

Redis behind a read-through endpoint

This is the read-through pattern that every replica shares. Start the app and call curl http://localhost:5000/products/A-1 twice: both calls return the same body, but only the first builds it, and a second replica pointed at the same Redis server also gets a hit. InstanceName is a key prefix, which matters because a Redis server is often shared between apps and plain keys collide.

Example · csharp
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Package: Microsoft.Extensions.Caching.StackExchangeRedis
// The connection string lives in configuration, never in the code.
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "shop:";   // key prefix, so two apps can share one server
});

WebApplication app = builder.Build();

app.MapGet("/products/{sku}", async (string sku, IDistributedCache cache, CancellationToken token) =>
{
    string key = $"product:{sku}";
    byte[]? cached = await cache.GetAsync(key, token);

    if (cached is not null)
    {
        return Results.Content(JsonSerializer.Deserialize<string>(cached)!, "application/json");
    }

    // Stands in for the real lookup.
    string json = $"{{\"sku\":\"{sku}\",\"name\":\"Blue mug\"}}";

    await cache.SetAsync(
        key,
        JsonSerializer.SerializeToUtf8Bytes(json),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) },
        token);

    return Results.Content(json, "application/json");
});

app.Run();

SetString shorthand next to the byte longhand

The String methods are extension methods on IDistributedCache rather than part of the interface, and they encode and decode UTF-8 for you. Use them when the value really is text, such as a pre-rendered JSON document. Recognising both forms matters because the interface itself only ever mentions byte[], which makes the shorthand easy to miss.

Example · csharp
using System.Text;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;

ServiceCollection services = new ServiceCollection();
services.AddDistributedMemoryCache();
IDistributedCache cache = services.BuildServiceProvider().GetRequiredService<IDistributedCache>();

DistributedCacheEntryOptions options = new DistributedCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromMinutes(20)
};

// Longhand: encode the text to UTF-8 bytes yourself.
byte[] bytes = Encoding.UTF8.GetBytes("ready");
await cache.SetAsync("status", bytes, options);

// Shorthand: SetStringAsync does exactly those two steps.
await cache.SetStringAsync("status", "ready", options);

// GetStringAsync is the matching shorthand for a get plus a decode.
string? value = await cache.GetStringAsync("status");
Console.WriteLine(value ?? "(missing)");

// Output:
// ready

Writing the GetOrCreate that the interface lacks

Most codebases that use IDistributedCache grow a helper like this, so write it once in a static class instead of repeating the four steps at every call site. Two limits to be honest about: it has no stampede protection, so several replicas can run the factory at the same moment, and a cached value is deserialised again on every hit. Both are reasons HybridCache was added.

Example · csharp
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;

public static class DistributedCacheJsonExtensions
{
    // IDistributedCache has no GetOrCreate, so read-through is written by hand.
    public static async Task<T> GetOrCreateJsonAsync<T>(
        this IDistributedCache cache,
        string key,
        Func<CancellationToken, Task<T>> factory,
        DistributedCacheEntryOptions options,
        CancellationToken token = default)
    {
        byte[]? cached = await cache.GetAsync(key, token);

        if (cached is not null)
        {
            return JsonSerializer.Deserialize<T>(cached)!;
        }

        T value = await factory(token);
        await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(value), options, token);
        return value;
    }
}

Discussion

  • Be the first to comment on this lesson.