EngineeringSolution ArchitectData and Decisions

Session 02 - Redis Caching Patterns

Azure Cache for Redis Tiers

TiervCPUMemorySLAUse Case
BasicShared250MB - 53GBNo SLADev/test
StandardDedicated250MB - 53GB99.9%Production cơ bản
PremiumDedicated6GB - 120GB99.9%High throughput, persistence, VNet
EnterpriseDedicated12GB - 2TB99.99%Redis modules, active geo-replication
Enterprise FlashDedicated384GB - 1.5TB99.99%Large datasets, cost-effective

Recommendation: Standard cho hầu hết workloads. Premium khi cần VNet integration hoặc data persistence. Enterprise cho mission-critical.

Cache-Aside Pattern với IDistributedCache

Pattern Overview

Implementation

csharp
// NuGet: Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "OrderService:";
});

// Generic cache-aside service
public class CacheAsideService
{
    private readonly IDistributedCache _cache;
    private readonly ILogger<CacheAsideService> _logger;

    public CacheAsideService(IDistributedCache cache, ILogger<CacheAsideService> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<T?> GetOrSetAsync<T>(
        string key,
        Func<Task<T?>> factory,
        TimeSpan? absoluteExpiration = null,
        TimeSpan? slidingExpiration = null,
        CancellationToken ct = default) where T : class
    {
        // 1. Try cache
        var cached = await _cache.GetStringAsync(key, ct);
        if (cached is not null)
        {
            _logger.LogDebug("Cache HIT: {Key}", key);
            return JsonSerializer.Deserialize<T>(cached);
        }

        // 2. Cache miss - load from source
        _logger.LogDebug("Cache MISS: {Key}", key);
        var value = await factory();

        if (value is null) return null;

        // 3. Set cache
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(10),
            SlidingExpiration = slidingExpiration ?? TimeSpan.FromMinutes(2)
        };

        var serialized = JsonSerializer.Serialize(value);
        await _cache.SetStringAsync(key, serialized, options, ct);

        return value;
    }

    public async Task InvalidateAsync(string key, CancellationToken ct = default)
    {
        await _cache.RemoveAsync(key, ct);
        _logger.LogDebug("Cache INVALIDATED: {Key}", key);
    }

    // Pattern: Invalidate by prefix (dùng StackExchange.Redis trực tiếp)
    public async Task InvalidateByPrefixAsync(string prefix)
    {
        // Lưu ý: KEYS command chỉ dùng cho small datasets
        // Production: dùng SET để track keys theo group
        _logger.LogDebug("Cache INVALIDATED prefix: {Prefix}*", prefix);
    }
}

// Usage trong controller
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(int id)
{
    var order = await _cacheService.GetOrSetAsync(
        $"order:{id}",
        () => _orderRepository.GetByIdAsync(id),
        absoluteExpiration: TimeSpan.FromMinutes(15));

    return order is null ? NotFound() : Ok(order);
}

Session Store

csharp
// Redis-backed session store
builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(30);
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.IsEssential = true;
});

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "Session:";
});

Pub/Sub for Real-time

csharp
using StackExchange.Redis;

public class RedisPubSubService
{
    private readonly IConnectionMultiplexer _redis;

    public RedisPubSubService(IConnectionMultiplexer redis) => _redis = redis;

    // Publish notification
    public async Task PublishOrderUpdateAsync(string orderId, string status)
    {
        var subscriber = _redis.GetSubscriber();
        var message = JsonSerializer.Serialize(new { orderId, status, timestamp = DateTime.UtcNow });
        await subscriber.PublishAsync(RedisChannel.Literal("order-updates"), message);
    }

    // Subscribe (typically in a BackgroundService)
    public async Task SubscribeToOrderUpdatesAsync(Action<string> handler)
    {
        var subscriber = _redis.GetSubscriber();
        await subscriber.SubscribeAsync(
            RedisChannel.Literal("order-updates"),
            (channel, message) => handler(message!));
    }
}

Redis Data Structures - Use Cases

StructureCommandUse CaseVí dụ
StringGET/SETCache objects, countersSession data, API response cache
HashHGET/HSETObject với multiple fieldsUser profile (name, email, role)
ListLPUSH/RPOPQueue, recent itemsRecent activity feed
SetSADD/SMEMBERSUnique collectionsOnline users, tags
Sorted SetZADD/ZRANGERanked dataLeaderboard, scheduled tasks
StreamXADD/XREADEvent streamingEvent log, message queue

Ví dụ: Leaderboard với Sorted Set

csharp
public class LeaderboardService
{
    private readonly IDatabase _db;
    private const string Key = "leaderboard:weekly";

    public LeaderboardService(IConnectionMultiplexer redis)
        => _db = redis.GetDatabase();

    public async Task AddScoreAsync(string userId, double score)
    {
        await _db.SortedSetAddAsync(Key, userId, score);
    }

    public async Task<IEnumerable<LeaderboardEntry>> GetTopAsync(int count = 10)
    {
        var entries = await _db.SortedSetRangeByRankWithScoresAsync(
            Key, 0, count - 1, Order.Descending);

        return entries.Select((e, i) => new LeaderboardEntry
        {
            Rank = i + 1,
            UserId = e.Element.ToString(),
            Score = e.Score
        });
    }

    public async Task<long?> GetRankAsync(string userId)
    {
        var rank = await _db.SortedSetRankAsync(Key, userId, Order.Descending);
        return rank.HasValue ? rank.Value + 1 : null;
    }
}

Connection Pooling - StackExchange.Redis

csharp
// QUAN TRONG: Chỉ tạo MỘT ConnectionMultiplexer instance
// ConnectionMultiplexer tự quản lý connection pool internally

builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var config = ConfigurationOptions.Parse(
        builder.Configuration.GetConnectionString("Redis")!);

    config.AbortOnConnectFail = false;       // Retry instead of throw
    config.ConnectRetry = 3;                  // Retry attempts
    config.ConnectTimeout = 5000;             // 5 seconds
    config.SyncTimeout = 3000;                // 3 seconds for sync ops
    config.AsyncTimeout = 5000;               // 5 seconds for async ops
    config.ReconnectRetryPolicy = new ExponentialRetry(5000); // Exponential backoff
    config.KeepAlive = 60;                    // Keepalive every 60 seconds

    // SSL for Azure Cache for Redis
    config.Ssl = true;
    config.SslProtocols = System.Security.Authentication.SslProtocols.Tls12;

    return ConnectionMultiplexer.Connect(config);
});

// Register IDatabase for convenience
builder.Services.AddScoped<IDatabase>(sp =>
    sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase());

Connection Best Practices

PracticeMô tả
Singleton multiplexerNEVER tạo nhiều instances - memory leak
AbortOnConnectFail = falseCho phép retry thay vì crash
Handle RedisTimeoutExceptionImplement retry với exponential backoff
Monitor connectionsTrack ConnectionMultiplexer.GetCounters()
Use firewall rulesRestrict access tới known IPs

TTL Strategies

StrategyPatternVí dụ
Fixed TTLMọi key cùng TTLProduct catalog: 1 giờ
Sliding TTLReset TTL mỗi lần accessSession data: 30 phút sliding
Write-throughUpdate cache khi write DBOrder status: immediate consistency
TTL with jitterTTL ± random offsetTránh thundering herd khi nhiều keys expire cùng lúc
No TTL + explicit invalidationInvalidate khi data thay đổiUser settings: cache forever, invalidate on update

Thundering Herd Prevention

csharp
// Thêm random jitter vào TTL để tránh nhiều keys expire cùng lúc
private static TimeSpan AddJitter(TimeSpan baseTtl)
{
    var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 60));
    return baseTtl + jitter;
}

// Usage
var options = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = AddJitter(TimeSpan.FromMinutes(10))
};

Trade-offs

Quyết địnhProCon
Redis vs in-memory cacheShared across instances, persistentNetwork latency, extra infra
Short TTLData luôn freshCache hit rate thấp, more DB load
Long TTLHigh cache hit rateStale data risk
Cache-asideSimple, app controlsCache miss penalty, consistency gaps
Write-throughStrong consistencyWrite latency tăng
Pub/Sub vs Service BusUltra-low latencyNo persistence, at-most-once delivery
Premium tierPersistence, VNetCost gấp 4-5x Standard