14.1 - Caching Fundamentals
Tóm tắt: Cache không miễn phí — nó đổi tính đúng đắn tức thời lấy tốc độ. Trước khi thêm cache, phải trả lời được hai câu: "dữ liệu này chịu được cũ bao lâu?" và "tỷ lệ trúng dự kiến là bao nhiêu?". Tỷ lệ trúng 20% gần như vô dụng: bạn trả chi phí quản lý cache, chiếm bộ nhớ, và thêm một nguồn bug — để tiết kiệm một phần năm số truy vấn. Cái bẫy kỹ thuật lớn nhất là cache stampede: cache hết hạn, 200 request đồng thời cùng thấy miss và cùng đi database, khiến thêm cache làm hệ thống sập vào đúng lúc tải cao. Và nhớ: cache sai là bug im lặng — không có exception, chỉ là người dùng thấy dữ liệu không đúng.
Mục tiêu bài học
Sau bài này bạn có thể:
- Quyết định có nên cache một thứ hay không, dựa trên số liệu.
- Tính và đo tỷ lệ trúng cache.
- Nhận ra cache stampede và biết cách chống.
- Chọn đúng loại cache cho từng nhu cầu.
- Liệt kê những gì không bao giờ nên cache.
Nội dung bài học
14.1.1 — Khi nào cache đáng giá
Bốn điều kiện, và cần cả bốn:
| Điều kiện | Vì sao |
|---|---|
| Đọc nhiều hơn ghi rõ rệt | Ghi nhiều thì cache liên tục bị xoá, tỷ lệ trúng thấp |
| Dữ liệu chịu được cũ | Nếu phải chính xác tuyệt đối thì không cache được |
| Tính toán hoặc truy vấn đắt | Cache một truy vấn 2ms không đáng |
| Khoá lặp lại | Tham số gần như không trùng thì mỗi request là một khoá mới |
Điều kiện cuối hay bị bỏ qua. Cache một endpoint tìm kiếm với từ khoá tự do: hầu như mọi từ khoá đều khác nhau, nên tỷ lệ trúng gần 0 trong khi bộ nhớ phình lên.
Tỷ lệ trúng là con số quyết định:
Ty le trung = so lan hit / (so lan hit + so lan miss)
< 50% : gan nhu vo ich, can xem lai
50-80% : có giá trị
> 80% : rat tot
> 95% : xuất sắc (dữ liệu tham chiếu, cấu hình)Luôn đo trước khi giữ lại một cache. Nhiều cache tồn tại trong code mà không ai biết nó có tác dụng hay không.
14.1.2 — Cache aside
Mẫu phổ biến nhất, và là mặc định:
public async Task<CustomerDto?> GetAsync(int id, CancellationToken ct)
{
var key = $"customer:{id}";
if (_cache.TryGetValue(key, out CustomerDto? cached))
return cached; // HIT
var customer = await _db.Customers
.Where(c => c.Id == id)
.Select(c => new CustomerDto(c.Id, c.Name, c.Email))
.FirstOrDefaultAsync(ct); // MISS -> di nguon
if (customer is not null)
_cache.Set(key, customer, TimeSpan.FromMinutes(5));
return customer;
}Ứng dụng quản lý cache; cache không biết gì về database. Đơn giản và chịu lỗi tốt: cache chết thì hệ thống vẫn chạy, chỉ chậm hơn.
Ba mẫu khác, và vì sao chúng hiếm dùng trong .NET:
| Mẫu | Cách hoạt động | Thực tế |
|---|---|---|
| Read-through | Cache tự nạp khi miss | Cần thư viện hỗ trợ; HybridCache gần như vậy |
| Write-through | Ghi cache và database cùng lúc | Ghi chậm hơn; ít khi đáng |
| Write-behind | Ghi cache trước, database sau | Mất dữ liệu nếu cache chết |
Write-behind nghe hấp dẫn về hiệu năng nhưng đánh đổi độ bền — chỉ hợp cho dữ liệu chấp nhận mất, như bộ đếm lượt xem.
14.1.3 — Cache stampede
Đây là cách mà thêm cache làm hệ thống sập:
t=0 Cache "top-products" het han
t=0.01 200 request đồng thời cùng thấy MISS
t=0.02 200 truy vấn GIỐNG HỆT NHAU cùng chạy
t=0.5 Database qua tai, moi truy van cham lai
t=2.0 Timeout hang loatNghịch lý: hệ thống không có cache sẽ chịu tải đều 200 truy vấn mỗi giây và sống sót; hệ thống có cache dồn tất cả vào một thời điểm và sập.
Ba cách chống:
// 1. Khoa theo khoa — chi MOT request di nguon
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public async Task<T?> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan ttl)
{
if (_cache.TryGetValue(key, out T? cached)) return cached;
var gate = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync();
try
{
if (_cache.TryGetValue(key, out cached)) return cached; // kiểm tra LẠI
var value = await factory();
_cache.Set(key, value, ttl);
return value;
}
finally { gate.Release(); }
}Kiểm tra lần hai bên trong khoá là bắt buộc — 199 request còn lại chờ xong sẽ thấy giá trị đã có và không đi database.
// 2. Cache Task<T> thay vi T — don gian hon
private readonly ConcurrentDictionary<string, Task<T>> _inFlight = new();
public Task<T> GetAsync(string key, Func<Task<T>> factory)
=> _inFlight.GetOrAdd(key, async _ =>
{
try { return await factory(); }
finally { _inFlight.TryRemove(key, out _); }
});Mẫu này đã nói ở bài 6.6: request đầu đặt Task đang chạy vào, các request sau cùng await nó.
// 3. TTL ngau nhien — trai deu thoi diem het han
var jitter = Random.Shared.Next(-30, 30);
_cache.Set(key, value, TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(jitter)));Cách 3 không chống được stampede cho một khoá, nhưng nó ngăn hàng nghìn khoá cùng hết hạn một lúc — điều xảy ra khi bạn nạp cache hàng loạt lúc khởi động.
HybridCache của .NET 9 làm sẵn cách 1 (bài 14.5).
14.1.4 — Bốn tầng cache
| Tầng | Ví dụ | Phạm vi | Độ trễ | Dùng cho |
|---|---|---|---|---|
| Trong tiến trình | IMemoryCache | Một instance | ~100ns | Dữ liệu tham chiếu, cấu hình |
| Phân tán | Redis | Cả cluster | ~1ms | Session, quyền, dữ liệu dùng chung |
| HTTP | Cache-Control, ETag | Client, CDN | 0 (không gọi) | Response công khai |
| CDN | Cloudflare | Toàn cầu | ~10ms | File tĩnh, API công khai |
Tầng HTTP đáng chú ý vì nó là tầng rẻ nhất: request không bao giờ tới server. Một Cache-Control: max-age=3600 trên endpoint danh sách quốc gia tiết kiệm nhiều hơn bất kỳ cache phía server nào (bài 9.7).
Kết hợp nhiều tầng là mẫu mạnh nhất: bộ nhớ trong tiến trình cho tốc độ, Redis cho chia sẻ giữa instance. Đó chính là HybridCache.
14.1.5 — Vấn đề khó nhất: xoá cache
"Trong khoa học máy tính chỉ có hai việc khó: xoá cache và đặt tên biến." — Phil Karlton
Khó vì cache sai không báo lỗi. Truy vấn sai thì ném exception; cache cũ thì chỉ là người dùng thấy dữ liệu không đúng, và có thể hàng tuần không ai phát hiện.
Ba chiến lược, chi tiết ở bài 14.4:
- TTL — để nó tự hết hạn. Đơn giản nhất, và trần rủi ro là chính TTL đó.
- Xoá chủ động — khi dữ liệu đổi. Chính xác nhưng dễ sót một đường ghi.
- Xoá theo tag — nhóm khoá liên quan và xoá cùng lúc.
TTL ngắn là lưới an toàn cho cả hai chiến lược kia. Kể cả khi bạn quên xoá ở một chỗ, dữ liệu sai chỉ sống tối đa bằng TTL. Đây là lý do TTL 5 phút thường tốt hơn TTL 1 giờ, dù tỷ lệ trúng thấp hơn.
14.1.6 — Không bao giờ cache
| Không cache | Vì sao |
|---|---|
| Số dư tài khoản, tồn kho lúc đặt hàng | Phải đúng tuyệt đối |
| Dữ liệu cá nhân trong cache dùng chung | Rò rỉ giữa người dùng |
| Mật khẩu, token, khoá | Bề mặt tấn công mới |
| Dữ liệu ghi nhiều hơn đọc | Tỷ lệ trúng thấp, chỉ tốn chi phí |
| Kết quả truy vấn có tham số gần như không lặp | Mỗi request một khoá mới |
Hàng thứ hai là lỗi bảo mật nghiêm trọng nhất: cache khoá "customer-list" mà không kèm tenant id hay user id, và người dùng B nhận dữ liệu của A.
Quy tắc: khoá cache phải chứa mọi thứ ảnh hưởng tới kết quả — tenant, người dùng, ngôn ngữ, quyền, tham số phân trang.
// SAI
var key = "customer-list";
// DUNG
var key = $"customers:t{tenantId}:u{userId}:p{page}:s{pageSize}:q{search}";14.1.7 — Rà lại code của bạn
Danh sách rà soát caching
- Đã trả lời được dữ liệu này chịu được cũ bao lâu.
- Tỷ lệ trúng được đo, không phỏng đoán.
- Cache có tỷ lệ trúng dưới 50% đã được xem xét bỏ đi.
- Khoá cache chứa mọi thứ ảnh hưởng kết quả, gồm tenant và người dùng.
- Không cache dữ liệu cá nhân vào kho dùng chung mà không phân vùng.
- Có cơ chế chống stampede cho khoá có lưu lượng cao.
- TTL có jitter nếu nhiều khoá được nạp cùng lúc.
- TTL đủ ngắn để làm trần rủi ro khi quên xoá cache.
- Đã cân nhắc cache tầng HTTP trước khi cache phía server.
- Không cache dữ liệu phải đúng tuyệt đối.
Bài tập áp dụng
Bài 1 — Đo tỷ lệ trúng
Thêm bộ đếm hit và miss cho một cache đang có, chạy một tuần, và tính tỷ lệ. Quyết định giữ hay bỏ dựa trên số đo.
Tiêu chí hoàn thành: bạn nêu được ngưỡng tỷ lệ trúng để một cache đáng giữ, và giải thích được vì sao ngưỡng đó phụ thuộc vào chi phí của cache miss.
Gợi ý và lời giải — Bài 1
Gợi ý. Cache không miễn phí. Hãy liệt kê chi phí của nó trước khi tính lợi ích.
Lời giải — thêm bộ đếm:
public class CacheDoDuoc : ICacheService
{
private static readonly Meter _meter = new("Crm.Cache");
private static readonly Counter<long> _hit = _meter.CreateCounter<long>("cache.hit");
private static readonly Counter<long> _miss = _meter.CreateCounter<long>("cache.miss");
private readonly IMemoryCache _cache;
public async Task<T?> LayHoacTaoAsync<T>(
string khoa, string ten, Func<Task<T>> factory, TimeSpan ttl, CancellationToken ct)
{
if (_cache.TryGetValue(khoa, out T? giaTri))
{
_hit.Add(1, new KeyValuePair<string, object?>("cache.name", ten));
return giaTri;
}
_miss.Add(1, new KeyValuePair<string, object?>("cache.name", ten));
giaTri = await factory();
_cache.Set(khoa, giaTri, ttl);
return giaTri;
}
}Gắn nhãn cache.name là chi tiết quan trọng: một tỷ lệ trúng gộp chung cho cả ứng dụng không cho bạn biết gì. Cần biết từng cache một, vì quyết định giữ hay bỏ là quyết định cho từng cái.
cache.name = "danh-muc-san-pham" hit 48.200 miss 310 -> 99,4%
cache.name = "thong-ke-dashboard" hit 1.840 miss 1.620 -> 53,2%
cache.name = "chi-tiet-lead" hit 420 miss 9.180 -> 4,4%Ngưỡng để một cache đáng giữ. Không có một con số duy nhất, nhưng có một công thức:
Tiết kiệm = tỷ-lệ-trúng × chi-phí-khi-không-cache
Chi phí = chi-phí-đọc-cache + chi-phí-ghi-cache + bộ-nhớ + rủi-ro-dữ-liệu-cũ
Đáng giữ khi: Tiết kiệm > Chi phí, một cách rõ rệtÁp vào ba cache ở trên:
| Cache | Tỷ lệ trúng | Truy vấn gốc | Kết luận |
|---|---|---|---|
| Danh mục sản phẩm | 99,4% | 80 ms | Giữ — tiết kiệm rõ rệt |
| Thống kê dashboard | 53,2% | 2.400 ms | Giữ — tỷ lệ thấp nhưng mỗi lần trúng đáng giá |
| Chi tiết lead | 4,4% | 3 ms | Bỏ — hầu như luôn miss, và truy vấn vốn đã rẻ |
Vì sao ngưỡng phụ thuộc vào chi phí của cache miss — đây là điểm chính.
So sánh hai dòng giữa. Dashboard chỉ trúng 53%, nghe như một cache kém. Nhưng mỗi lần trúng tiết kiệm 2,4 giây:
Dashboard: 0,532 × 2.400 ms = 1.277 ms tiết kiệm trung bình mỗi lần gọi
Chi tiết lead: 0,044 × 3 ms = 0,13 ms tiết kiệm trung bình mỗi lần gọiChi phí đọc IMemoryCache khoảng 0,05–0,1 ms, chi phí Redis khoảng 0,5–2 ms. Với dashboard, chi phí đó là nhiễu. Với chi tiết lead dùng Redis, cache còn chậm hơn không cache — mỗi lần gọi tốn thêm khoảng 1,4 ms cho một truy vấn 3 ms.
Quy tắc rút gọn:
Truy vấn > 1 giây: trúng 30% đã đáng giữ
Truy vấn 100–500 ms: cần khoảng 70%
Truy vấn 10–50 ms: cần trên 90%
Truy vấn < 5 ms: gần như không bao giờ đáng cache qua mạngVì sao "chi tiết lead" trúng thấp như vậy — và đây là bài học thiết kế:
_cache.Set($"lead:{id}", lead, TimeSpan.FromMinutes(5));Cache theo từng bản ghi với TTL ngắn. Với 200.000 lead và mỗi lead được xem vài lần mỗi tháng, xác suất hai lượt xem cùng một lead rơi vào cùng một cửa sổ 5 phút là rất thấp. Cache đang lưu 200.000 mục mà gần như không bao giờ được đọc lại.
Đây là mẫu sai phổ biến nhất: cache thứ có nhiều khoá và ít lượt truy cập mỗi khoá. Cache chỉ hoạt động khi có điểm nóng — một số ít khoá chiếm phần lớn lượt đọc.
Cache tốt: ít khoá, nhiều lượt đọc mỗi khoá (danh mục, cấu hình, tỷ giá)
Cache tệ: nhiều khoá, ít lượt đọc mỗi khoá (chi tiết từng bản ghi)Bốn chi phí của cache mà người ta hay quên khi tính:
- Bộ nhớ — 200.000 entity trong
IMemoryCachelà vài trăm MB, và nó cạnh tranh với phần còn lại của tiến trình. - Dữ liệu cũ — mọi lần trúng đều là dữ liệu của quá khứ. Với TTL 5 phút, người dùng có thể thấy dữ liệu cũ 5 phút sau khi ai đó sửa.
- Độ phức tạp — code xoá cache, kiểm tra tính nhất quán, và những lỗi chỉ xuất hiện khi cache trúng.
- Một nguồn lỗi mới — Redis chết, serialize thất bại, khoá va chạm.
Ba chi phí cuối không đo bằng mili giây được, nên chúng thường bị bỏ ra ngoài phép tính. Nhưng chúng là lý do một cache trúng 4,4% không chỉ vô ích mà có hại.
Đưa tỷ lệ trúng lên dashboard và đặt cảnh báo:
cache.hit / (cache.hit + cache.miss) theo cache.name
Cảnh báo khi một cache tụt dưới 50% trong 24 giờTỷ lệ trúng tụt đột ngột là dấu hiệu sớm của nhiều vấn đề: TTL bị đặt sai, khoá cache bị thêm thành phần mới, xoá cache quá tay, hoặc mẫu truy cập đã thay đổi và cache không còn phù hợp.
Bài 2 — Tái hiện cache stampede
Cache một truy vấn mất 500 ms với TTL 10 giây, bắn 200 request đồng thời ngay sau khi hết hạn, và đếm số truy vấn database. Thêm khoá theo khoá và đếm lại.
Tiêu chí hoàn thành: bạn giải thích được vì sao GetOrCreateAsync không chống được stampede, dù tên gọi gợi ý điều ngược lại.
Gợi ý và lời giải — Bài 2
Gợi ý. Trong 500 ms mà factory đang chạy, cache chứa gì?
Lời giải — đo thật, trên .NET 9.0.203, Microsoft.Extensions.Caching.Memory 9.0.3 và Microsoft.Extensions.Caching.Hybrid 9.3.0, 200 request đồng thời, truy vấn mất 500 ms:
var tasks = Enumerable.Range(0, 200).Select(async _ =>
await cache.GetOrCreateAsync("k", e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
return TruyVanCham(dem); // dem[0]++ rồi Task.Delay(500)
}));
await Task.WhenAll(tasks);IMemoryCache.GetOrCreateAsync factory chạy 200 lần 513 ms
IMemoryCache + SemaphoreSlim factory chạy 1 lần 503 ms
HybridCache.GetOrCreateAsync factory chạy 1 lần 536 msCả 200 request đều chạy factory. Trên một database thật, đó là 200 truy vấn nặng dội cùng lúc.
Vì sao GetOrCreateAsync không chống được stampede. Tên gọi gợi ý một thao tác nguyên tử "lấy hoặc tạo", nhưng phần cài đặt là hai bước tách rời và không có khoá nào giữa chúng:
// Ý nghĩa thật của GetOrCreateAsync
if (cache.TryGetValue(key, out var value)) return value; // bước 1
value = await factory(); // bước 2 — mất 500 ms
cache.Set(key, value);
return value;Trong 500 ms mà bước 2 đang chạy, cache vẫn rỗng. Mọi request đến trong khoảng đó đều trượt ở bước 1 và cùng chạy bước 2:
t=0,000 request 1 TryGetValue -> miss -> bắt đầu truy vấn
t=0,001 request 2 TryGetValue -> miss -> bắt đầu truy vấn
t=0,002 request 3 TryGetValue -> miss -> bắt đầu truy vấn
...
t=0,050 request 200 TryGetValue -> miss -> bắt đầu truy vấn
t=0,500 cả 200 cùng xong, cùng gọi Set("k")IMemoryCache bảo đảm thread-safety — hai luồng ghi cùng lúc không làm hỏng cấu trúc dữ liệu. Nhưng thread-safety không phải là điều phối: nó không hứa rằng factory chỉ chạy một lần.
Và điều này càng tệ khi truy vấn càng chậm: số request kịp chen vào tỉ lệ thuận với thời gian factory chạy. Một truy vấn 5 giây dưới tải cao có thể kéo theo hàng nghìn lượt thực thi song song — đủ để làm sập chính database mà cache được dựng lên để bảo vệ.
Bản sửa — khoá theo từng khoá, kèm kiểm tra lại:
public class CacheChongStampede
{
private readonly IMemoryCache _cache;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _khoa = new();
public async Task<T> LayHoacTaoAsync<T>(
string khoa, Func<Task<T>> factory, TimeSpan ttl, CancellationToken ct)
{
if (_cache.TryGetValue(khoa, out T? giaTri)) return giaTri!;
var semaphore = _khoa.GetOrAdd(khoa, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
try
{
// Kiểm tra LẠI — người vào trước có thể đã điền cache rồi
if (_cache.TryGetValue(khoa, out giaTri)) return giaTri!;
giaTri = await factory();
_cache.Set(khoa, giaTri, ttl);
return giaTri;
}
finally
{
semaphore.Release();
if (semaphore.CurrentCount == 1) _khoa.TryRemove(khoa, out _);
}
}
}Ba chi tiết quyết định:
- Kiểm tra lại sau khi vào khoá. Không có nó, 200 request lần lượt vào khoá và lần lượt chạy factory — chậm hơn cả bản gốc, vì giờ chúng tuần tự.
- Khoá theo từng khoá cache, không phải một khoá toàn cục. Một
SemaphoreSlimdùng chung sẽ chặn cả những request đang hỏi khoá khác. - Dọn semaphore sau khi dùng xong, nếu không dictionary lớn dần mãi — chính nó trở thành rò rỉ bộ nhớ.
Chi tiết 3 có một điểm tinh tế: kiểm tra CurrentCount == 1 rồi mới xoá là một heuristic, không phải bảo đảm tuyệt đối. Trong trường hợp xấu, một semaphore bị xoá trong khi request khác vừa lấy nó — hậu quả là factory chạy hai lần thay vì một, chứ không phải lỗi. Đánh đổi này chấp nhận được; muốn chặt chẽ hơn thì dùng HybridCache.
Kết quả sau khi sửa: 1 lần chạy factory thay vì 200, và tổng thời gian không đổi (503 ms so với 513 ms). 199 request kia chờ trên semaphore rồi nhận giá trị từ cache — chúng không chờ lâu hơn, vì dù sao chúng cũng phải đợi truy vấn đó xong.
Cách tốt hơn: HybridCache của .NET 9 — chống stampede sẵn có, không phải tự viết:
builder.Services.AddHybridCache();var lead = await _cache.GetOrCreateAsync(
$"lead:{id}",
async token => await _db.Leads.FirstOrDefaultAsync(l => l.Id == id, token),
cancellationToken: ct);Đo được 1 lần chạy factory, giống bản tự viết, mà không có ba chi tiết dễ sai ở trên. Chi tiết ở bài 14.5.
Hai kỹ thuật bổ sung cho hệ thống nhiều instance:
1. TTL có nhiễu ngẫu nhiên — tránh mọi instance cùng hết hạn một lúc:
var ttl = TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(Random.Shared.Next(0, 120));Không có nó, 10 instance cùng nạp cache lúc khởi động sẽ cùng hết hạn 10 phút sau — và bạn có stampede xuyên instance, thứ mà khoá cục bộ không chặn được.
2. Làm mới sớm — nạp lại trước khi hết hạn, từ một request nền:
if (conLai < ttl * 0.2) // còn dưới 20% thời gian sống
_ = Task.Run(() => LamMoiAsync(khoa), ct); // làm mới ngầm, không chặn requestRequest hiện tại vẫn nhận giá trị cũ ngay lập tức, nên không ai phải chờ 500 ms — kể cả người xui xẻo đến đúng lúc hết hạn.
Bài 3 — Rò rỉ dữ liệu giữa người dùng
Cache kết quả có lọc theo quyền với khoá không kèm user id. Đăng nhập hai tài khoản khác quyền và xác nhận tài khoản thứ hai thấy dữ liệu của tài khoản đầu.
Tiêu chí hoàn thành: bạn nêu được quy tắc xác định mọi thành phần phải có trong khoá cache, và biết cách kiểm tra tự động.
Gợi ý và lời giải — Bài 3
Gợi ý. Liệt kê mọi thứ mà kết quả trả về phụ thuộc vào. Rồi so với những thứ có trong khoá.
Lời giải — bản có lỗi:
public async Task<List<LeadDto>> LayDanhSachAsync(CancellationToken ct)
{
return await _cache.GetOrCreateAsync("danh-sach-lead", async e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
var query = _db.Leads.AsNoTracking();
// Kết quả phụ thuộc vào người dùng — nhưng khoá thì không
if (!_currentUser.LaQuanTri)
query = query.Where(l => l.AssignedUserId == _currentUser.Id);
return await query.Select(l => new LeadDto(l.Id, l.Name, l.Value)).ToListAsync(ct);
});
}# An là quản trị
curl -H "Authorization: Bearer <token-cua-an>" http://localhost:5000/leads
# -> 2.400 lead (toàn bộ)
# Bình là nhân viên, chỉ được xem lead của mình
curl -H "Authorization: Bearer <token-cua-binh>" http://localhost:5000/leads
# -> 2.400 lead <- SAI, đáng ra chỉ 18 leadBình thấy toàn bộ dữ liệu, kể cả lead của khách hàng mà Bình không được phép biết. Và nếu Bình gọi trước, An sẽ chỉ thấy 18 lead — cùng một lỗi, biểu hiện ngược lại.
Đây là lỗi phân quyền, không phải lỗi hiệu năng, và nó nghiêm trọng hơn vẻ ngoài: nó vượt qua mọi kiểm tra phân quyền trong code, vì code phân quyền không chạy khi cache trúng.
Quy tắc xác định mọi thành phần phải có trong khoá:
Khoá cache phải chứa mọi biến mà kết quả phụ thuộc vào.
Cách áp dụng: nhìn vào thân hàm factory và liệt kê mọi thứ nó đọc từ bên ngoài tham số.
// Factory đọc những gì?
// _currentUser.LaQuanTri <- biến, phải vào khoá
// _currentUser.Id <- biến, phải vào khoá
// _tenantProvider.Id <- nếu có, phải vào khoá
// TimeSpan.FromMinutes(5) <- hằng, không cầnvar khoa = $"danh-sach-lead:tenant={_tenantProvider.Id}" +
$":user={_currentUser.Id}:admin={_currentUser.LaQuanTri}";Sáu loại thành phần hay bị sót:
| Thành phần | Khi nào cần | Hậu quả nếu sót |
|---|---|---|
| Tenant id | Hệ thống multi-tenant | Rò rỉ dữ liệu giữa khách hàng |
| User id hoặc role | Kết quả lọc theo quyền | Rò rỉ dữ liệu giữa người dùng |
| Tham số truy vấn | Có lọc, tìm kiếm | Trả về kết quả của bộ lọc khác |
| Trang và cỡ trang | Có phân trang | Trả về sai trang |
| Ngôn ngữ | Có i18n | Hiển thị sai ngôn ngữ |
| Phiên bản schema DTO | DTO đổi cấu trúc | Lỗi deserialize sau khi deploy |
Hai dòng đầu in đậm vì chúng là vấn đề bảo mật, không phải vấn đề đúng sai thông thường.
Ba cách giảm rủi ro, theo thứ tự nên chọn:
1. Tốt nhất — cache thứ không phụ thuộc vào người dùng. Cache dữ liệu thô, lọc quyền sau khi lấy từ cache:
// Cache một lần cho cả tenant — không có chiều người dùng
var tatCa = await _cache.GetOrCreateAsync(
$"lead:tenant={tenantId}",
async _ => await _db.Leads.Where(l => l.TenantId == tenantId).ToListAsync(ct));
// Lọc quyền sau khi lấy từ cache — luôn chạy, mọi request
var ketQua = _currentUser.LaQuanTri
? tatCa
: tatCa.Where(l => l.AssignedUserId == _currentUser.Id).ToList();Cách này tốt hơn hẳn về ba mặt: tỷ lệ trúng cao hơn nhiều (một mục cho cả tenant thay vì một mục mỗi người dùng), bộ nhớ ít hơn, và logic phân quyền luôn được thực thi — nó nằm ngoài cache nên không có đường nào đi vòng qua nó.
Giới hạn: chỉ dùng được khi tập dữ liệu thô đủ nhỏ để giữ trong bộ nhớ.
2. Sinh khoá tập trung, không viết chuỗi rải rác:
public sealed record KhoaCache
{
public required string Ten { get; init; }
public required string TenantId { get; init; }
public string? UserId { get; init; }
public string? ThamSo { get; init; }
public override string ToString()
=> $"{Ten}:t={TenantId}" +
(UserId is null ? "" : $":u={UserId}") +
(ThamSo is null ? "" : $":p={ThamSo}");
}required trên TenantId là điểm mấu chốt: trình biên dịch buộc mọi chỗ tạo khoá phải cung cấp tenant. Không thể quên.
3. Đặt tiền tố tenant ở tầng cache, không ở tầng gọi:
public class CacheTheoTenant : ICacheService
{
private string ThemTienTo(string khoa) => $"{_tenantProvider.Id}:{khoa}";
// mọi phương thức đều đi qua đây
}Không ai ở tầng gọi cần nhớ tới tenant nữa, vì họ không có cách nào bỏ qua nó.
Kiểm tra tự động — hai lớp:
1. Test hai người dùng, cùng một tiến trình:
[Fact]
public async Task Hai_nguoi_dung_khac_quyen_phai_thay_du_lieu_khac_nhau()
{
await using var factory = new WebApplicationFactory<Program>(); // dùng chung MỘT factory
var cuaAn = await GoiApiAsync(factory, "an-quan-tri");
var cuaBinh = await GoiApiAsync(factory, "binh-nhan-vien");
cuaAn.Should().HaveCountGreaterThan(cuaBinh.Count);
cuaBinh.Should().OnlyContain(l => l.AssignedUserId == "binh-nhan-vien");
}Như ở bài 13.10, dùng chung một factory là điều bắt buộc — tạo factory mới cho request thứ hai sẽ cho test xanh dù code sai.
2. Test cấu trúc khoá, bắt được cả những cache viết sau này:
[Theory]
[MemberData(nameof(MoiKhoaCacheTrongUngDung))]
public void Moi_khoa_cache_phai_chua_tenant(string khoa)
{
khoa.Should().Contain(":t=", "khoá cache thiếu tenant thì rò rỉ dữ liệu giữa khách hàng");
}Và một cảnh báo về IMemoryCache dùng chung tiến trình: nó không có ranh giới nào giữa các tenant. Một khoá va chạm là dữ liệu của tenant này nằm trong bộ nhớ và được trả về cho tenant khác — không có lớp bảo vệ nào phía dưới. Với Redis, ít nhất bạn còn có thể tách theo database hoặc theo tiền tố ở tầng kết nối.
Tự kiểm tra
Bốn điều kiện để cache đáng giá là gì?
Đọc nhiều hơn ghi rõ rệt, dữ liệu chịu được cũ, tính toán hoặc truy vấn đắt, và khoá lặp lại. Cần cả bốn; điều kiện cuối hay bị bỏ qua, ví dụ cache endpoint tìm kiếm với từ khoá tự do thì hầu như mọi khoá đều khác nhau nên tỷ lệ trúng gần không.
Tỷ lệ trúng bao nhiêu thì cache có ý nghĩa?
Dưới 50 phần trăm thì gần như vô ích vì bạn trả chi phí quản lý, bộ nhớ và thêm một nguồn bug để tiết kiệm phần nhỏ số truy vấn. Từ 50 tới 80 là có giá trị, trên 80 là rất tốt, và trên 95 thường là dữ liệu tham chiếu hoặc cấu hình.
Cache stampede là gì và vì sao nó nghịch lý?
Khi cache hết hạn, nhiều request đồng thời cùng thấy miss và cùng đi nguồn, dồn toàn bộ tải vào một thời điểm. Nghịch lý là hệ thống không có cache sẽ chịu tải đều và sống sót, còn hệ thống có cache thì sập vào đúng lúc tải cao.
Ba cách chống stampede là gì?
Khoá theo từng khoá cache với kiểm tra lại bên trong khoá, cache Task thay vì cache giá trị để các request sau cùng await một Task, và thêm jitter vào TTL để nhiều khoá không cùng hết hạn một lúc. HybridCache của .NET 9 làm sẵn cách đầu.
Vì sao xoá cache là vấn đề khó?
Vì cache sai không báo lỗi. Truy vấn sai thì ném exception, còn cache cũ chỉ là người dùng thấy dữ liệu không đúng, và có thể hàng tuần không ai phát hiện. TTL ngắn là lưới an toàn vì nó giới hạn thời gian dữ liệu sai tồn tại.
Khoá cache phải chứa những gì?
Mọi thứ ảnh hưởng tới kết quả: tenant, người dùng, ngôn ngữ, quyền và tham số phân trang. Thiếu tenant hay user id trong khoá là lỗi bảo mật nghiêm trọng vì người dùng này sẽ nhận dữ liệu của người khác.
Kết luận
Ba điều đáng nhớ nhất:
- Đo tỷ lệ trúng trước khi giữ lại một cache. Dưới 50% thì nó chỉ là chi phí.
- Stampede khiến cache làm hệ thống sập đúng lúc tải cao.
- Khoá cache phải chứa tenant và người dùng, nếu không nó là lỗ hổng dữ liệu.
Tham khảo
- Overview of caching in ASP.NET Core
- Caching guidance (Azure Architecture Center)
- Cache-Aside pattern
- RFC 9111 — HTTP Caching
Điều hướng
- Bài trước: Không có (bài mở đầu module).
- Bài tiếp theo: 14.2 — IMemoryCache
- Về module: Trang mục lục