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:
- Martin Fowler: TwoHardThings
- Microsoft: Cache-Aside Pattern
- Redis: Keyspace Notifications
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