WEBSITE ĐANG PHÁT TRIỂN

Caching strategy cho product catalog - invalidation mới là bài toán khó

Cache thì dễ thêm vào. Nhưng khi product price thay đổi lúc 11:59 PM trước flash sale lúc 12:00 AM, bạn mới biết cache invalidation khó như thế nào. Bài này đi sâu vào các pattern thực tế và code C# cho product catalog. Phil Karlton có câu nói nổi tiếng: "There are only two hard things in Computer Science: cache invalidation and naming things." Tôi thêm vào: cache invalidation trong e-commerce còn khó hơn cache invalidation ở chỗ khác. Vì trong e-commerce, data thay đổi liên tục - price, stock, promotion - và mỗi inconsistency đều có thể cost bạn money (hoặc cost khách hàng).

Vấn đề: Product catalog là gì mà cần cache?

Product catalog trong e-commerce gồm:

  • Product information: Tên, mô tả, hình ảnh, category, attributes
  • Pricing: Giá gốc, giá sale, price by customer segment
  • Inventory: Stock count, warehouse location
  • Promotions: Discount rules, bundle offers, flash sale prices

Read/write ratio thường là 95:5 - người đọc catalog rất nhiều, người update (admin) ít. Đây là perfect use case cho caching.

Nhưng 5% write đó có thể xảy ra vào thời điểm critical nhất. Và đó là nơi mọi thứ trở nên phức tạp.


Giải thích đơn giản: Các tầng cache trong e-commerce

Trước khi đi vào code, hãy hiểu cấu trúc:

[Browser Cache]  → Client-side, TTL ngắn (1-5 phút)
       ↓
[CDN/Edge Cache] → Cloudflare, Azure Front Door, TTL medium (5-60 phút)
       ↓
[Application Cache] → Redis, In-memory, TTL tùy logic
       ↓
[Database Cache]  → SQL Server buffer pool, implicit
       ↓
[Database]        → Source of truth

Mỗi tầng có trade-off riêng về consistency, latency, và cost. Bài này focus vào Application Cache với Redis vì đây là tầng mà developer có control nhiều nhất.


Code C# minh họa: Cache Aside Pattern

Cache Aside là pattern phổ biến nhất - application tự manage cache, không phải cache tự fill.

public class ProductCatalogService : IProductCatalogService
{
    private readonly IProductRepository _repository;
    private readonly IDistributedCache _cache;
    private readonly ILogger<ProductCatalogService> _logger;

    // TTL constants - đây là nơi bạn tune theo business logic
    private static readonly TimeSpan ProductTtl = TimeSpan.FromMinutes(30);
    private static readonly TimeSpan PriceTtl = TimeSpan.FromMinutes(5); // Ngắn hơn vì price thay đổi nhiều hơn
    private static readonly TimeSpan StockTtl = TimeSpan.FromMinutes(1); // Rất ngắn vì stock critical

    public async Task<ProductDto?> GetProductAsync(int productId, CancellationToken ct = default)
    {
        var cacheKey = CacheKeys.Product(productId);

        // 1. Try cache first
        var cached = await _cache.GetStringAsync(cacheKey, ct);
        if (cached is not null)
        {
            return JsonSerializer.Deserialize<ProductDto>(cached);
        }

        // 2. Cache miss - load from DB
        var product = await _repository.GetByIdAsync(productId, ct);
        if (product is null) return null;

        var dto = product.ToDto();

        // 3. Store in cache
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = ProductTtl
        };
        await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(dto), options, ct);

        return dto;
    }

    public async Task<PriceDto?> GetProductPriceAsync(int productId, int? customerId = null, CancellationToken ct = default)
    {
        // Price cache key phụ thuộc vào customer segment nếu có
        var cacheKey = customerId.HasValue
            ? CacheKeys.CustomerPrice(productId, customerId.Value)
            : CacheKeys.BasePrice(productId);

        var cached = await _cache.GetStringAsync(cacheKey, ct);
        if (cached is not null)
        {
            return JsonSerializer.Deserialize<PriceDto>(cached);
        }

        var price = await _repository.GetPriceAsync(productId, customerId, ct);
        if (price is null) return null;

        var dto = price.ToDto();
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = PriceTtl
        };
        await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(dto), options, ct);

        return dto;
    }
}

public static class CacheKeys
{
    public static string Product(int productId) => $"product:{productId}";
    public static string BasePrice(int productId) => $"price:base:{productId}";
    public static string CustomerPrice(int productId, int customerId) => $"price:customer:{productId}:{customerId}";
    public static string ProductsByCategory(int categoryId, int page) => $"category:{categoryId}:page:{page}";
}

Đây là happy path. Bây giờ mới đến phần khó.


Invalidation strategies - so sánh và khi nào dùng

Strategy 1: TTL-based expiration (Time-to-Live)

Cache entry tự expire sau một khoảng thời gian. Đơn giản nhất.

// TTL 30 phút cho product info
var options = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
};

Pros: Simple, no coordination needed, predictable behavior

Cons: Stale data trong TTL window. Nếu admin update price lúc 11:50 PM và TTL là 30 phút, khách hàng có thể thấy price cũ cho đến 12:20 AM.

Khi dùng: Product descriptions, images, static content. Những thứ không change-sensitive.

Strategy 2: Write-through invalidation (Event-driven)

Khi có update, invalidate cache ngay lập tức.

public class ProductUpdateService : IProductUpdateService
{
    private readonly IProductRepository _repository;
    private readonly IDistributedCache _cache;
    private readonly IEventBus _eventBus;

    public async Task UpdateProductPriceAsync(int productId, decimal newPrice, CancellationToken ct = default)
    {
        // 1. Update database
        await _repository.UpdatePriceAsync(productId, newPrice, ct);

        // 2. Invalidate cache immediately
        await InvalidateProductCacheAsync(productId, ct);

        // 3. Publish event cho downstream systems (search index, CDN, etc.)
        await _eventBus.PublishAsync(new ProductPriceUpdatedEvent(productId, newPrice), ct);
    }

    private async Task InvalidateProductCacheAsync(int productId, CancellationToken ct)
    {
        var keysToInvalidate = new[]
        {
            CacheKeys.Product(productId),
            CacheKeys.BasePrice(productId),
            // Customer-specific prices cần pattern-based deletion
        };

        var tasks = keysToInvalidate.Select(key => _cache.RemoveAsync(key, ct));
        await Task.WhenAll(tasks);

        _logger.LogInformation("Cache invalidated for product {ProductId}", productId);
    }
}

Pros: Data consistent ngay sau update

Cons: Cần coordination giữa write path và cache. Cache stampede nếu nhiều requests đến cùng lúc khi cache trống.

Khi dùng: Price changes, stock updates, flash sale activation.

Strategy 3: Cache stampede prevention (Probabilistic Early Expiration)

Vấn đề: Khi cache expire, nếu 1000 users cùng hit product đó một lúc, tất cả đều miss và cùng query database. Database quá tải.

public async Task<ProductDto?> GetProductWithStampedeProtectionAsync(
    int productId,
    CancellationToken ct = default)
{
    var cacheKey = CacheKeys.Product(productId);
    var lockKey = $"lock:{cacheKey}";

    // Thử get từ cache
    var cached = await _cache.GetStringAsync(cacheKey, ct);
    if (cached is not null)
    {
        return JsonSerializer.Deserialize<ProductDto>(cached);
    }

    // Dùng distributed lock để chỉ một request được query DB
    await using var lockHandle = await _lockManager.AcquireAsync(lockKey, timeout: TimeSpan.FromSeconds(5), ct);

    if (lockHandle is null)
    {
        // Không lấy được lock - thử lại cache một lần nữa (có thể đã được fill bởi winner)
        cached = await _cache.GetStringAsync(cacheKey, ct);
        return cached is not null
            ? JsonSerializer.Deserialize<ProductDto>(cached)
            : null; // Fallback: return null hoặc query DB không cache
    }

    // Acquired lock - bây giờ chúng ta là "winner", query DB
    var product = await _repository.GetByIdAsync(productId, ct);
    if (product is null) return null;

    var dto = product.ToDto();
    await _cache.SetStringAsync(
        cacheKey,
        JsonSerializer.Serialize(dto),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ProductTtl },
        ct);

    return dto;
}

Best practices từ thực chiến

1. Separate TTL cho từng loại data

Đừng dùng một TTL cho tất cả. Price nhạy cảm hơn product description. Stock critical hơn cả hai.

public static class CacheTtl
{
    public static readonly TimeSpan ProductInfo = TimeSpan.FromMinutes(30);
    public static readonly TimeSpan BasePrice = TimeSpan.FromMinutes(5);
    public static readonly TimeSpan FlashSalePrice = TimeSpan.FromSeconds(30); // Rất ngắn trong flash sale
    public static readonly TimeSpan StockCount = TimeSpan.FromMinutes(1);
    public static readonly TimeSpan CategoryList = TimeSpan.FromHours(1);
}

2. Cache key naming convention

Nhất quán, predictable, có version support:

// Xấu
"product_123"
"p123"
"product123data"

// Tốt - namespace:entity:id[:version]
"v1:product:123"
"v1:price:base:123"
"v1:price:customer:123:456"

3. Graceful degradation khi cache down

public async Task<ProductDto?> GetProductAsync(int productId, CancellationToken ct = default)
{
    try
    {
        var cached = await _cache.GetStringAsync(CacheKeys.Product(productId), ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<ProductDto>(cached);
    }
    catch (Exception ex)
    {
        // Cache down - log nhưng không throw, fall through to DB
        _logger.LogWarning(ex, "Cache unavailable for product {ProductId}, falling back to database", productId);
    }

    // Fallback to database - hệ thống vẫn hoạt động, chỉ chậm hơn
    var product = await _repository.GetByIdAsync(productId, ct);
    return product?.ToDto();
}

4. Monitoring cache hit rate

Cache hit rate < 80% là warning sign. Cần investigate tại sao cache miss nhiều - TTL quá ngắn, key collision, hay invalidation quá aggressive.


Kết luận + Tham khảo

Cache invalidation khó không phải vì kỹ thuật phức tạp. Khó vì nó đòi hỏi bạn hiểu rõ business trade-off: consistency vs performance, simplicity vs flexibility.

Trong e-commerce, rule of thumb của tôi:

  • Product info: TTL-based, 30 phút
  • Base price: Write-through + TTL backup, 5 phút
  • Stock: Write-through, 1 phút, monitor closely
  • Flash sale price: Write-through + event-driven, TTL 30 giây trong sale window

Không có giải pháp one-size-fits-all. Nhưng nếu bạn hiểu trade-off, bạn có thể chọn đúng cho từng trường hợp.

Tham khảo:


Các bạn đang làm e-commerce gặp vấn đề gì với caching? Comment cho anh biết - mỗi bài toán thực tế đều có góc nhìn khác nhau.

/Son Do - believe in basic

#1percentbetter #dotnet #caching #ecommerce #systemdesign


Bài viết liên quan

Xem thêm
E-commerce & Search Systems

Azure Event Hub trong pipeline xử lý đơn hàng real-time

Azure Event Hub không phải message queue - đây là streaming platform. Sự khác biệt này ảnh hưởng đến mọi design decision. Bài này là architecture và code cho order processing pipeline dùng Event Hub - từ kinh nghiệm thực tế.

E-commerce & Search Systems

Search relevance: tại sao người dùng tìm 'áo đỏ' lại ra 'váy xanh

Search relevance không phải là "tìm từ nào match từ đó". Đằng sau một kết quả tìm kiếm là cả một hệ thống scoring phức tạp - và nếu không hiểu nó, bạn sẽ cứ nhận complaint "search dở" mà không biết fix ở đâu.