24. Redis for distributed caching

  1. Redis for distributed caching

About this chapter

Improve API performance using Redis for distributed caching with the cache-aside pattern, managing expiration strategies and cache invalidation.

  • When to cache: Identifying cacheable data based on frequency and cost
  • Redis setup: Running and connecting to Redis in your infrastructure
  • Cache-aside pattern: Check cache, fall back to database, populate cache
  • Cache expiration: Absolute expiration, manual invalidation, and staleness
  • Key naming conventions: Organizing cache keys for clarity and isolation
  • Cache invalidation: Keeping cached data fresh when underlying data changes

Learning outcomes:

  • Understand caching benefits and trade-offs
  • Set up Redis and create connections in ASP.NET Core
  • Implement the cache-aside pattern for read operations
  • Choose appropriate cache expiration strategies
  • Handle cache misses and database fallback
  • Invalidate cache when data changes

24.1 When to Cache

Caching trades memory for speed. Cache data that is:

  1. Frequently accessed - no point caching data nobody reads
  2. Expensive to compute - queries, external API calls, calculations
  3. Acceptable to be stale - exact freshness isn’t critical (e.g., user counts, product details)

Never cache:

  • Real-time data (stock prices, live scores)
  • User-specific sensitive data
  • Small datasets that are fast anyway
  • Anything requiring guaranteed accuracy

24.2 Redis Setup

Redis is already in your docker-compose.yaml. It’s a fast in-memory data store used for caching, sessions, and temporary data.

# Start Redis
docker-compose up -d redis

# Verify connection
redis-cli ping
# Output: PONG

24.3 Cache-Aside Pattern (Existing Implementation)

The cache-aside pattern is simple: check cache first, fall back to database if miss, then populate cache.

// Current implementation in PgSqlRegistrationRepository
public class PgSqlRegistrationRepository : IRegistrationRepository
{
    private readonly AppDbContext _context;
    private readonly IDatabase _redisDb;

    public async Task<KeyRegistration?> GetRegistrationByIndex(string keyIndex)
    {
        // 1. Check cache
        var cachedRegistration = await _redisDb.StringGetAsync($"KeyRegistration:{keyIndex}");

        if (!cachedRegistration.IsNullOrEmpty)
        {
            return JsonConvert.DeserializeObject<KeyRegistration>(cachedRegistration!);
        }

        // 2. Cache miss—fetch from database
        var keyRegistration = await _context.keyRegistrations
            .FirstOrDefaultAsync(k => k.KeyIndex.ToString() == keyIndex);

        // 3. Store in cache if found
        if (keyRegistration != null)
        {
            TimeSpan expiry = TimeSpan.FromHours(1);
            await _redisDb.StringSetAsync(
                $"KeyRegistration:{keyRegistration.KeyIndex}",
                JsonConvert.SerializeObject(keyRegistration),
                expiry);
        }

        return keyRegistration;
    }
}

Why this pattern: Simple, works with any database, cache failures are graceful (just slower).

24.4 Cache Expiration

Data in cache expires based on your strategy:

// Absolute expiration: Expires after fixed time
await _redisDb.StringSetAsync(
    key: "platform:1",
    value: platformJson,
    expiry: TimeSpan.FromHours(1));  // Expires in 1 hour regardless

// No expiration: Cache until manually cleared
await _redisDb.StringSetAsync(
    key: "config:app",
    value: configJson);

// Manual invalidation when data changes
await _redisDb.KeyDeleteAsync("platform:1");

Choose expiration based on data freshness requirements:

  • Platforms: 1 hour (not critical if slightly stale)
  • API keys: 1 hour (validation is fast anyway)
  • Config: No expiration (manually invalidated on changes)

24.5 Applying Cache-Aside to Platforms

The code comment suggests caching platforms. Here’s the implementation:

public class PgSqlCommandRepository : ICommandRepository
{
    private readonly AppDbContext _context;
    private readonly IDatabase _redisDb;
    private const string PlatformCachePrefix = "platform:";
    private const int PlatformCacheDurationHours = 1;

    public async Task<Platform?> GetPlatformByIdAsync(int id)
    {
        var cacheKey = $"{PlatformCachePrefix}{id}";

        // Try cache first
        var cached = await _redisDb.StringGetAsync(cacheKey);
        if (!cached.IsNullOrEmpty)
        {
            return JsonConvert.DeserializeObject<Platform>(cached!);
        }

        // Cache miss—fetch from database
        var platform = await _context.Platforms
            .Include(p => p.Commands)
            .FirstOrDefaultAsync(p => p.Id == id);

        // Store in cache if found
        if (platform != null)
        {
            await _redisDb.StringSetAsync(
                cacheKey,
                JsonConvert.SerializeObject(platform),
                TimeSpan.FromHours(PlatformCacheDurationHours));
        }

        return platform;
    }

    // When updating/deleting, invalidate the cache
    public async Task UpdatePlatformAsync(Platform platform)
    {
        await _redisDb.KeyDeleteAsync($"{PlatformCachePrefix}{platform.Id}");
    }

    public void DeletePlatform(Platform platform)
    {
        _context.Platforms.Remove(platform);
        _redisDb.KeyDelete($"{PlatformCachePrefix}{platform.Id}");
    }
}

Key principle: When data changes, invalidate its cache entry so the next request fetches fresh data.

24.6 Cache Invalidation Patterns

Single Item Invalidation (most common):

[HttpPut("{id}")]
public async Task<ActionResult> UpdatePlatform(int id, PlatformMutateDto dto)
{
    var platform = await _repository.GetPlatformByIdAsync(id);
    _mapper.Map(dto, platform);
    await _repository.UpdatePlatformAsync(platform);
    await _repository.SaveChangesAsync();

    // Clear cache for this platform
    await _redisDb.KeyDeleteAsync($"platform:{id}");

    return NoContent();
}

Related Items Invalidation (when deleting a parent affects children):

[HttpDelete("{id}")]
public async Task<ActionResult> DeletePlatform(int id)
{
    var platform = await _repository.GetPlatformByIdAsync(id);

    _repository.DeletePlatform(platform);
    await _repository.SaveChangesAsync();

    // Invalidate platform and all its commands
    await _redisDb.KeyDeleteAsync($"platform:{id}");
    foreach (var cmd in platform.Commands)
    {
        await _redisDb.KeyDeleteAsync($"command:{cmd.Id}");
    }

    return NoContent();
}

Pattern Invalidation (clear all matching keys):

public async Task InvalidateAllPlatformCache()
{
    var server = _redis.GetServer(_redis.GetEndPoints().First());
    var keys = server.Keys(pattern: "platform:*");  // All platform cache entries

    foreach (var key in keys)
    {
        await _redisDb.KeyDeleteAsync(key);
    }
}