Distributed Caching with Redis

Share cached data across replicas with IDistributedCache and Redis: registration, serialisation, key design, TTLs and surviving a Redis outage.

Syntaxbuilder.Services.AddStackExchangeRedisCache(o => { o.Configuration = builder.Configuration.GetConnectionString("Redis"); o.InstanceName = "orders-api:"; }); await cache.SetAsync(key, bytes, new() { AbsoluteExpirationRelativeToNow = ttl });

IDistributedCache is a deliberately small contract: GetAsync, SetAsync, RefreshAsync and RemoveAsync on string keys and byte[] values, plus string helpers such as GetStringAsync. Every replica sees the same entries, a deploy does not empty the cache, and a write evicted by one pod is gone for all of them. The price is a network round trip and serialisation on every read.

Registering Redis

The Microsoft.Extensions.Caching.StackExchangeRedis package adds AddStackExchangeRedisCache. Set Configuration to a StackExchange.Redis connection string and InstanceName to a prefix such as orders-api:, which is prepended to every key so several services can share one Redis without colliding. AddDistributedMemoryCache() registers an in-process implementation of the same interface: fine for tests and local runs, not distributed at all.

Serialisation and key design

You choose the byte format. System.Text.Json with JsonSerializer.SerializeToUtf8Bytes is the usual choice; use a source-generated context on hot paths. Design keys as {entity}:{schema-version}:{id}, for example customer-summary:v2:1042. The version segment matters during a rolling deploy: old and new pods run side by side, and if the new code renames a property, the old pods deserialise new entries into defaults (or the reverse) for the length of the TTL. Bumping the version gives each code version its own keys. Never build keys from raw user input without normalising it, and keep values small: Redis executes commands one at a time, so a 2 MB value delays every other client while it is sent.

Expiry

OptionBehaviour
noneThe entry never expires. Calling SetAsync(key, bytes) without options does this.
AbsoluteExpirationRelativeToNowRedis expires the key after the period. The right default.
SlidingExpirationEach read also resets the expiry, an extra write per hit; cap it with an absolute limit.

Always pass options; a team-wide wrapper that requires a TTL removes the never-expiring key class of bug. Configure Redis itself with a maxmemory and an eviction policy such as allkeys-lru or volatile-lru. Self-hosted Redis defaults to noeviction, which turns a full cache into write errors instead of evictions.

When Redis is slow or down

A cache is an optimisation, so a Redis outage should cost latency, not availability. The StackExchange.Redis defaults are a 5-second connect timeout and a 5-second synchronous timeout; with those, every request waits up to 5 seconds before failing. Set abortConnect=false so a failed first connection does not throw on the first cache call; the multiplexer keeps reconnecting in the background. (RedisCache connects lazily on the first cache operation, so app startup never waits for Redis either way.) Then shorten the timeouts, and wrap cache calls so a RedisException or timeout falls back to the database and is logged and counted. Watch the database too: losing a cache with a 95% hit rate multiplies database load by twenty.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Cache a customer's order summary for 10 minutes so all six API replicas share one computed copy instead of running the aggregate six times.
  • Cache the rendered invoice PDF metadata for GET /invoices/{id} for 1 hour in Redis so a customer re-opening the page on another pod skips the totals query.
  • Keep the parsed product feed from a supplier in Redis so a deploy that restarts every pod does not trigger a full reload storm.

More examples

Registering Redis with production timeouts

Parsing the connection string into <code>ConfigurationOptions</code> lets you set timeouts in code while the host, port and password still come from configuration. The defaults (5 seconds to connect and per operation) are tuned for not giving up, which for a cache means each request can hang for 5 seconds when Redis is unhealthy. <code>InstanceName</code> is a plain prefix, so include the trailing colon yourself.

Example · csharp
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddStackExchangeRedisCache(options =>
{
    var redis = ConfigurationOptions.Parse(builder.Configuration.GetConnectionString("Redis")!);
    redis.AbortOnConnectFail = false; // a failed first connect does not throw; keep reconnecting in the background
    redis.ConnectTimeout = 2_000;
    redis.SyncTimeout = 1_000;
    redis.AsyncTimeout = 1_000;
    options.ConfigurationOptions = redis;
    options.InstanceName = "orders-api:";
});

var app = builder.Build();
app.MapGet("/healthz", () => TypedResults.Ok("ok"));
app.Run();

A fail-open decorator so Redis outages cost latency, not errors

A miss is always a valid answer from a cache, so the decorator turns Redis failures into misses and the caller loads from the database. <code>RedisTimeoutException</code> derives from <code>TimeoutException</code> and connection failures from <code>RedisException</code>, so the filter catches both without swallowing bugs such as a serialisation error. Register it by resolving the Redis implementation in a factory, and add a metric next to the log so a dashboard shows the outage.

Example · csharp
using Microsoft.Extensions.Caching.Distributed;
using StackExchange.Redis;

public sealed class FailOpenDistributedCache(IDistributedCache inner, ILogger<FailOpenDistributedCache> logger) : IDistributedCache
{
    public byte[]? Get(string key) => Try(() => inner.Get(key), null);

    public Task<byte[]?> GetAsync(string key, CancellationToken token = default) =>
        TryAsync(() => inner.GetAsync(key, token), null);

    public void Set(string key, byte[] value, DistributedCacheEntryOptions options) =>
        Try(() => { inner.Set(key, value, options); return true; }, false);

    public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) =>
        TryAsync(async () => { await inner.SetAsync(key, value, options, token); return true; }, false);

    public void Refresh(string key) => Try(() => { inner.Refresh(key); return true; }, false);

    public Task RefreshAsync(string key, CancellationToken token = default) =>
        TryAsync(async () => { await inner.RefreshAsync(key, token); return true; }, false);

    // Removal failures are logged as errors: a missed eviction means stale data until the TTL.
    public void Remove(string key) => Try(() => { inner.Remove(key); return true; }, false, LogLevel.Error);

    public Task RemoveAsync(string key, CancellationToken token = default) =>
        TryAsync(async () => { await inner.RemoveAsync(key, token); return true; }, false, LogLevel.Error);

    private T Try<T>(Func<T> action, T fallback, LogLevel level = LogLevel.Warning)
    {
        try { return action(); }
        catch (Exception ex) when (ex is RedisException or TimeoutException)
        {
            logger.Log(level, ex, "Distributed cache unavailable; continuing without it");
            return fallback;
        }
    }

    private async Task<T> TryAsync<T>(Func<Task<T>> action, T fallback, LogLevel level = LogLevel.Warning)
    {
        try { return await action(); }
        catch (Exception ex) when (ex is RedisException or TimeoutException)
        {
            logger.Log(level, ex, "Distributed cache unavailable; continuing without it");
            return fallback;
        }
    }
}

appsettings.Production.json for the Redis connection

The connection string carries the StackExchange.Redis options, so timeouts can differ per environment without a rebuild. Keep the real password out of the file: in Kubernetes the whole string usually arrives as <code>ConnectionStrings__Redis</code> from a Secret. <code>ssl=true</code> with port 6380 is the common shape for managed Redis services that require TLS.

Example · json
{
  "ConnectionStrings": {
    // The password is injected from the secret store as an environment variable at deploy time.
    "Redis": "orders-redis.internal:6380,ssl=true,password=<from-secret-store>,abortConnect=false,connectTimeout=2000,syncTimeout=1000"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "StackExchange.Redis": "Warning"
    }
  }
}

Discussion

  • Be the first to comment on this lesson.