Chuyển tới nội dung chính

14.2 — 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.2.1 — Khi nào cache đáng giá​

Bốn điều kiện, và cần cả bốn:

Điều kiệnVì sao
Đọc nhiều hơn ghi rõ rệtGhi 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 đắtCache một truy vấn 2ms không đáng
Khoá lặp lạiTham 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.2.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ẫuCách hoạt độngThực tế
Read-throughCache tự nạp khi missCần thư viện hỗ trợ; HybridCache gần như vậy
Write-throughGhi cache và database cùng lúcGhi chậm hơn; ít khi đáng
Write-behindGhi cache trước, database sauMấ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.2.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 loat

Nghị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.7: 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.6).

14.2.4 — Bốn tầng cache​

TầngVí dụPhạm viĐộ trễDùng cho
Trong tiến trìnhIMemoryCacheMột instance~100nsDữ liệu tham chiếu, cấu hình
Phân tánRedisCả cluster~1msSession, quyền, dữ liệu dùng chung
HTTPCache-Control, ETagClient, CDN0 (không gọi)Response công khai
CDNCloudflareToàn cầu~10msFile 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.8).

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.2.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.5:

  1. TTL — để nó tự hết hạn. Đơn giản nhất, và trần rủi ro là chính TTL đó.
  2. Xoá chủ động — khi dữ liệu đổi. Chính xác nhưng dễ sót một đường ghi.
  3. 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.2.6 — Không bao giờ cache​

Không cacheVì sao
Số dư tài khoản, tồn kho lúc đặt hàngPhải đúng tuyệt đối
Dữ liệu cá nhân trong cache dùng chungRò 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 đọcTỷ 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ặpMỗ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.2.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:

CacheTỷ lệ trúngTruy vấn gốcKết luận
Danh mục sản phẩm99,4%80 msGiữ — tiết kiệm rõ rệt
Thống kê dashboard53,2%2.400 msGiữ — tỷ lệ thấp nhưng mỗi lần trúng đáng giá
Chi tiết lead4,4%3 msBỏ — 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ọi

Chi 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ạng

Vì 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:

  1. Bộ nhớ — 200.000 entity trong IMemoryCache là vài trăm MB, và nó cạnh tranh với phần còn lại của tiến trình.
  2. 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.
  3. Độ 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.
  4. 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 ms

Cả 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:

  1. 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ự.
  2. Khoá theo từng khoá cache, không phải một khoá toàn cục. Một SemaphoreSlim dùng chung sẽ chặn cả những request đang hỏi khoá khác.
  3. 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 request

Request 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 lead

Bì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ần
var 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ầnKhi nào cầnHậu quả nếu sót
Tenant idHệ thống multi-tenantRò rỉ dữ liệu giữa khách hàng
User id hoặc roleKết quả lọc theo quyềnRò rỉ dữ liệu giữa người dùng
Tham số truy vấnCó lọc, tìm kiếmTrả về kết quả của bộ lọc khác
Trang và cỡ trangCó phân trangTrả về sai trang
Ngôn ngữCó i18nHiển thị sai ngôn ngữ
Phiên bản schema DTODTO đổi cấu trúcLỗ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​

Câu hỏi thường gặp

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:

  1. Đo tỷ lệ trúng trước khi giữ lại một cache. Dưới 50% thì nó chỉ là chi phí.
  2. Stampede khiến cache làm hệ thống sập đúng lúc tải cao.
  3. 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​

Điều hướng​