EngineeringSolution ArchitectSystem Design

Session 02 - Caching Strategy

Tổng quan

Caching là kỹ thuật lưu trữ tạm thời dữ liệu ở tầng nhanh hơn để giảm latency và giảm tải cho data source gốc. Đúng strategy giúp giảm 10-100x response time; sai strategy gây stale data và bug khó debug.

Các Caching Patterns

Cache-Aside (Lazy Loading)

Application tự quản lý cache: đọc cache trước, nếu miss thì đọc DB rồi ghi vào cache.

csharp
public class ProductService
{
    private readonly IDistributedCache _cache;
    private readonly ProductDbContext _db;
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);

    public async Task<Product?> GetProductAsync(int id, CancellationToken ct = default)
    {
        var cacheKey = $"product:{id}";

        // 1. Thử đọc từ cache
        var cached = await _cache.GetStringAsync(cacheKey, ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<Product>(cached);

        // 2. Cache miss - đọc từ DB
        var product = await _db.Products.FindAsync(new object[] { id }, ct);
        if (product is null)
            return null;

        // 3. Ghi vào cache
        await _cache.SetStringAsync(cacheKey,
            JsonSerializer.Serialize(product),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheDuration },
            ct);

        return product;
    }
}

Ưu điểm: Chỉ cache data thực sự được request, đơn giản. Nhược điểm: Cache miss đầu tiên luôn chậm, có thể bị stale data trong TTL window.

Write-Through

Mọi write đều ghi vào cả cache và DB đồng thời. Đảm bảo cache luôn consistent.

csharp
public async Task UpdateProductAsync(Product product, CancellationToken ct = default)
{
    // 1. Ghi DB trước (source of truth)
    _db.Products.Update(product);
    await _db.SaveChangesAsync(ct);

    // 2. Cập nhật cache ngay lập tức
    var cacheKey = $"product:{product.Id}";
    await _cache.SetStringAsync(cacheKey,
        JsonSerializer.Serialize(product),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) },
        ct);
}

Ưu điểm: Cache luôn fresh, read performance tốt. Nhược điểm: Write latency tăng (phải ghi 2 nơi), cache có thể chứa data không bao giờ được đọc.

Write-Behind (Write-Back)

Ghi vào cache trước, batch ghi vào DB sau (async). Phù hợp write-heavy workload.

csharp
// Sử dụng Channel để buffer writes
public class WriteBehindProductService
{
    private readonly Channel<Product> _writeChannel =
        Channel.CreateBounded<Product>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait
        });

    public async Task UpdateProductAsync(Product product, CancellationToken ct)
    {
        // 1. Ghi cache ngay (fast path)
        var cacheKey = $"product:{product.Id}";
        await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product), ct);

        // 2. Enqueue để ghi DB sau
        await _writeChannel.Writer.WriteAsync(product, ct);
    }

    // Background service batch ghi DB
    public async Task ProcessWritesAsync(CancellationToken ct)
    {
        var batch = new List<Product>();
        await foreach (var product in _writeChannel.Reader.ReadAllAsync(ct))
        {
            batch.Add(product);
            if (batch.Count >= 50 || !_writeChannel.Reader.TryPeek(out _))
            {
                await BulkUpdateDbAsync(batch, ct);
                batch.Clear();
            }
        }
    }
}

Ưu điểm: Write rất nhanh, giảm tải DB nhờ batching. Nhược điểm: Rủi ro mất data nếu cache crash trước khi flush, eventually consistent.

So sánh Cache Technologies

Tiêu chíIn-Memory (MemoryCache)RedisCDN
Latency< 1ms (cùng process)1-5ms (network hop)Tùy geo (0.5-50ms)
CapacityGiới hạn bởi RAM serverLên đến 120GB/nodeKhông giới hạn
Shared giữa instancesKhôngCóCó
PersistenceKhông (mất khi restart)Có (AOF/RDB)Có
Data typesObject referenceString, Hash, List, Set, Sorted SetStatic content
Chi phíFree (dùng RAM app)~$200-1000/tháng (Azure)~$0.01-0.10/GB
Phù hợpSingle instance, hot dataDistributed, session, pub/subStatic files, API response

Azure Cache for Redis Setup

bash
# Tạo Redis instance (Premium tier cho production)
az redis create \
  --resource-group myRG \
  --name myapp-redis \
  --location southeastasia \
  --sku Premium \
  --vm-size p1 \
  --enable-non-ssl-port false \
  --minimum-tls-version 1.2

# Lấy connection string
az redis show \
  --resource-group myRG \
  --name myapp-redis \
  --query "{Host:hostName, Port:sslPort}" -o table

az redis list-keys \
  --resource-group myRG \
  --name myapp-redis

Đăng ký trong ASP.NET Core

csharp
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration["Redis:ConnectionString"];
    options.InstanceName = "myapp:";
});

// Hoặc dùng trực tiếp ConnectionMultiplexer cho advanced scenarios
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var config = ConfigurationOptions.Parse(
        builder.Configuration["Redis:ConnectionString"]);
    config.AbortOnConnectFail = false;
    config.ConnectRetry = 3;
    config.SyncTimeout = 3000;
    return ConnectionMultiplexer.Connect(config);
});

Cache Invalidation Strategies

"There are only two hard things in Computer Science: cache invalidation and naming things." - Phil Karlton

1. TTL-Based (Time-To-Live)

Đơn giản nhất. Set expiry time, cache tự expire.

csharp
await _cache.SetStringAsync(key, value,
    new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10), // Hard deadline
        SlidingExpiration = TimeSpan.FromMinutes(2) // Reset nếu có access
    });

2. Event-Based Invalidation

Invalidate cache khi data thay đổi thông qua events.

csharp
public class ProductUpdatedHandler : INotificationHandler<ProductUpdatedEvent>
{
    private readonly IDistributedCache _cache;

    public async Task Handle(ProductUpdatedEvent notification, CancellationToken ct)
    {
        // Xóa cache cụ thể
        await _cache.RemoveAsync($"product:{notification.ProductId}", ct);

        // Xóa cache liên quan (listing, category...)
        await _cache.RemoveAsync($"products:category:{notification.CategoryId}", ct);
        await _cache.RemoveAsync("products:featured", ct);
    }
}

3. Version-Based (Cache Busting)

Thêm version vào cache key. Khi data thay đổi, tăng version.

csharp
public async Task<List<Product>> GetCategoryProductsAsync(int categoryId, CancellationToken ct)
{
    var version = await _cache.GetStringAsync($"category-version:{categoryId}", ct) ?? "0";
    var cacheKey = $"products:category:{categoryId}:v{version}";

    var cached = await _cache.GetStringAsync(cacheKey, ct);
    if (cached is not null)
        return JsonSerializer.Deserialize<List<Product>>(cached)!;

    var products = await _db.Products.Where(p => p.CategoryId == categoryId).ToListAsync(ct);
    await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(products), ct);
    return products;
}

// Khi update: tăng version -> key cũ tự hết TTL
public async Task InvalidateCategoryAsync(int categoryId, CancellationToken ct)
{
    await _cache.IncrementAsync($"category-version:{categoryId}");
}

Cache Stampede Prevention

Cache stampede (hay thundering herd) xảy ra khi nhiều request cùng lúc gặp cache miss và đồng loạt query DB.

Giải pháp: Locking (Mutex)

csharp
public class StampedeProtectedCache
{
    private readonly IDistributedCache _cache;
    private readonly IDatabase _redisDb;
    private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);

    public async Task<T?> GetOrCreateAsync<T>(
        string key,
        Func<Task<T>> factory,
        TimeSpan expiry,
        CancellationToken ct = default) where T : class
    {
        // 1. Thử đọc cache
        var cached = await _cache.GetStringAsync(key, ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<T>(cached);

        // 2. Acquire distributed lock
        var lockKey = $"lock:{key}";
        var lockValue = Guid.NewGuid().ToString();
        var acquired = await _redisDb.StringSetAsync(
            lockKey, lockValue, LockTimeout, When.NotExists);

        if (!acquired)
        {
            // Chờ và thử lại - request khác đang build cache
            await Task.Delay(100, ct);
            cached = await _cache.GetStringAsync(key, ct);
            return cached is not null ? JsonSerializer.Deserialize<T>(cached) : null;
        }

        try
        {
            // 3. Double-check sau khi có lock
            cached = await _cache.GetStringAsync(key, ct);
            if (cached is not null)
                return JsonSerializer.Deserialize<T>(cached);

            // 4. Build cache
            var result = await factory();
            await _cache.SetStringAsync(key,
                JsonSerializer.Serialize(result),
                new DistributedCacheEntryOptions
                    { AbsoluteExpirationRelativeToNow = expiry },
                ct);
            return result;
        }
        finally
        {
            // 5. Release lock (chỉ nếu vẫn là owner)
            await _redisDb.ScriptEvaluateAsync(
                "if redis.call('get',KEYS[1])==ARGV[1] then redis.call('del',KEYS[1]) end",
                new RedisKey[] { lockKey },
                new RedisValue[] { lockValue });
        }
    }
}

Trade-off Summary

Quyết địnhOption AOption BGuideline
TTL ngắn vs dàiNgắn (1-5 phút): data fresh hơnDài (30-60 phút): ít load DBTùy data change frequency
Local vs Distributed cacheLocal: nhanh hơn, đơn giảnDistributed: consistent giữa instancesDùng cả hai (L1/L2 cache)
Invalidation: TTL vs EventTTL: đơn giảnEvent: chính xác hơnEvent cho critical data, TTL cho phần còn lại
Serialize: JSON vs BinaryJSON: debug dễBinary (MessagePack): nhỏ/nhanh hơnBinary cho production, JSON cho dev