Cache Redis trong ASP.NET Core: khi nào, ở đâu, ra sao
Cách dùng Redis với ASP.NET Core: IMemoryCache vs IDistributedCache, cache-aside vs write-through, chiến lược invalidation, và cách kết nối dùng được ở scale.
Mục lục
- Khi nào thêm cache thực sự giúp?
- Nên ngân sách những con số nào cho quyết định cache?
- Kiến trúc cache-aside tối thiểu trông thế nào?
- Cấu hình .NET 10 cho IDistributedCache + Redis là gì?
- Invalidate sao cho không hỏng mọi thứ?
- Cache giới thiệu failure mode nào?
- Khi nào không nên thêm cache?
- Đi tiếp đâu từ đây?
Lần đầu một service đọc nhiều hơn database chịu nổi, câu trả lời
đúng là cache đọc lại. Lần thứ hai, câu trả lời đúng là
invalidate cache mà không làm hỏng mọi thứ. Chương này xử lý cả hai
bằng công cụ .NET có sẵn - phần lớn là năm dòng Program.cs cộng
một extension method.
Khi nào thêm cache thực sự giúp?
Ba dấu hiệu cache hợp lý.
Tỉ lệ đọc cao - cùng dữ liệu được đọc nhiều gấp 10 lần so với thay đổi. Catalogue sản phẩm, hồ sơ user, feature flag. Cache gộp 10 lần đọc DB thành 1 + 9 lần đọc cache, và Redis nhanh hơn Postgres khoảng 20x cho lookup theo key.
Giá trị tính toán đắt - join ba bảng, kiểm tra quyền, suy luận ML. DB phục vụ được input nhưng phép tính mới đắt. Cache kết quả, không cache input.
Tải biến động với đuôi nóng - 1% item nhận 90% traffic (phân phối Zipfian, hình dạng web điển hình). Cache 1% nóng giữ DB không bùng nổ trong đỉnh.
Dấu hiệu chống cache cũng rõ: tỉ lệ read/write thấp (hit rate sẽ thảm), yêu cầu tươi nghiêm ngặt (chi phí invalidation > chi phí DB), hay workload đã vừa với page cache của DB (Postgres tự cache page nóng vào RAM miễn phí).
Nên ngân sách những con số nào cho quyết định cache?
Thao tác Latency QPS mỗi node
IMemoryCache.Get ~50 ns triệu
Redis GET (LAN) ~0.5 ms 100K-1M
Postgres SELECT theo PK ~1-3 ms 30K-100K
Postgres SELECT có join ~5-50 ms <10K
DynamoDB GetItem ~5-10 ms tuỳ RCU
Quy tắc thô: cache PK lookup Postgres đem lại ~5x; cache join mua ~50x; cache giá trị tính toán mua tới 1000x. Lợi lớn hơn biện minh cho phức tạp invalidation cao hơn.
Kiến trúc cache-aside tối thiểu trông thế nào?
Vòng lặp kinh điển, vẽ ra:
flowchart LR
Client --> App[ASP.NET Core]
App -->|1. GET key| Cache[(Redis)]
Cache -->|2a. hit| App
Cache -->|2b. miss| App
App -->|3. khi miss<br/>SELECT| DB[(Postgres)]
DB --> App
App -->|4. SET key, TTL| Cache
App --> Client
Mỗi read thử cache trước; khi miss, app query DB và ghi kết quả lại với TTL. Write invalidate (hoặc để TTL hết) các key liên quan. Đây là pattern trong 90% service .NET và scale xa hơn nhiều người tưởng.
Cấu hình .NET 10 cho IDistributedCache + Redis là gì?
Năm dòng Program.cs:
builder.Services.AddStackExchangeRedisCache(opt =>
{
opt.Configuration = builder.Configuration.GetConnectionString("Redis");
opt.InstanceName = "anhtu-dev:";
});
// Sau đó trong service bất kỳ:
public class ProductService(
IDistributedCache cache,
AppDbContext db,
ILogger<ProductService> log)
{
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct)
{
var key = $"product:{id}";
var cached = await cache.GetStringAsync(key, ct);
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await db.Products.FindAsync([id], ct);
if (product is null) return null;
await cache.SetStringAsync(
key,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) },
ct);
return product;
}
}
Ba chi tiết. Prefix InstanceName chống va chạm key giữa các service
chia sẻ một Redis. AbsoluteExpiration được ưu tiên hơn
SlidingExpiration vì dự đoán được. JsonSerializer là tuỳ chọn
nhẹ nhất; cho path nóng, MessagePack cắt payload 3-5x.
Invalidate sao cho không hỏng mọi thứ?
Ba chiến lược, tăng dần đúng đắn và chi phí:
- Chỉ TTL - đơn giản nhất, chấp nhận stale bằng TTL. Phù hợp data đọc nhiều, đúng đắn thấp.
- Invalidate khi write - mỗi write xoá key cache. Rủi ro: nếu xoá fail, cache vẫn cũ đến TTL.
- Write-through + publish/subscribe - khi write, publish key lên Redis channel; mọi replica nghe và xoá IMemoryCache local của mình. Chỉ cần khi có cache hai tầng.
Luôn kết hợp với TTL. Message invalidate có thể lạc; TTL là phòng tuyến cuối. Combo này cho bạn deploy mà không mất ngủ.
Cache giới thiệu failure mode nào?
Bốn cái cổ điển:
- Cache stampede - nhiều request cùng miss key vừa hết hạn và cùng tải lên DB. Sửa: single-flight locking (chỉ một request populate) hoặc pre-warm key trước khi hết hạn.
- Stale-after-write - write thành công, invalidate cache fail thầm lặng. Sửa: log có cấu trúc + alert khi invalidation fail; dùng TTL làm phòng tuyến cuối.
- Hot key - một key Redis nhận quá nhiều traffic làm CPU Redis
quá tải. Sửa: shard key (
product:{id}:{shard%4}) hoặc pre-fetch vào IMemoryCache mỗi máy. - Memory bloat - cache lớn vô hạn vì mọi entry TTL dài và không
evict. Sửa: cấu hình
maxmemory-policy allkeys-lrutrên Redis; đừng chỉ dựa TTL cho dung lượng.
Chương 13 cho cách lộ
cache_hits_total, cache_misses_total, cache_size_bytes qua
OpenTelemetry để theo dõi cả bốn.
Khi nào không nên thêm cache?
Khi database gốc phục vụ được tải. Thêm Redis kéo theo deployment mới, failure mode mới, và contract invalidation. Nếu QPS read dưới 1000 và DB latency đã <10 ms, cache không mua gì và tốn vận hành. Với tay vào cache khi toán từ chương 2 cho thấy DB không theo kịp, hoặc khi chi phí join trong chương 5 thành điểm nghẽn.
Đi tiếp đâu từ đây?
Chương kế tiếp: chọn database cho app .NET - cách quyết định giữa SQL và NoSQL, khi nào read replica giúp, và shard thực sự tốn gì. Cache là cái bạn dùng tới trước; chọn database quyết định bạn có bao giờ cần đến không.