Skip to content

14.2 - IMemoryCache ​

Tóm tắt: IMemoryCache nhanh nhất có thể — dữ liệu nằm ngay trong heap của tiến trình, không serialize, không qua mạng. Ba cái bẫy đi kèm. Không đặt SizeLimit là rò rỉ bộ nhớ: cache lớn vô hạn cho tới khi GC áp lực hoặc tiến trình bị OOM kill; nhưng đặt SizeLimit thì mọi entry bắt buộc phải khai Size, nếu không Set ném exception. SlidingExpiration một mình có thể khiến entry sống mãi — mỗi lần đọc lại gia hạn, nên khoá được truy cập liên tục không bao giờ hết hạn. Và GetOrCreateAsync không chống stampede: nhiều request đồng thời cùng miss sẽ cùng chạy factory.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Cấu hình SizeLimit và Size nhất quán.
  • Chọn đúng giữa absolute và sliding expiration.
  • Chống stampede cho IMemoryCache.
  • Tránh lưu entity EF Core vào cache.
  • Đo và giám sát cache trong bộ nhớ.

Nội dung bài học ​

14.2.1 — SizeLimit là bắt buộc ​

csharp
// SAI — cache lon vo han
builder.Services.AddMemoryCache();

Không có giới hạn, cache chỉ bị dọn khi GC báo áp lực bộ nhớ — và trong container có giới hạn bộ nhớ cứng, tiến trình thường bị OOM kill trước khi điều đó xảy ra.

csharp
// DUNG
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 10_000;                         // đơn vị TỰ ĐỊNH NGHĨA
    options.CompactionPercentage = 0.25;                // don 25% khi day
    options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);
});

SizeLimit không có đơn vị — nó là con số trừu tượng mà bạn tự định nghĩa ý nghĩa. Ba cách quy ước phổ biến:

Quy ướcSize mỗi entrySizeLimit
Đếm số entry1Số entry tối đa
Ước lượng byteSố byte xấp xỉTổng byte
Theo độ nặng1 cho nhỏ, 10 cho lớnTổng trọng số

Cách đầu đơn giản nhất và đủ cho hầu hết trường hợp.

Cái bẫy: khi đã đặt SizeLimit, mọi entry phải khai Size:

csharp
_cache.Set("key", value);      // InvalidOperationException neu co SizeLimit
_cache.Set("key", value, new MemoryCacheEntryOptions { Size = 1 });   // OK

Lỗi này xuất hiện lúc chạy, ở một chỗ nào đó trong code mà ai đó quên. Cách phòng là bọc lại:

csharp
public sealed class AppCache(IMemoryCache cache)
{
    public void Set<T>(string key, T value, TimeSpan ttl, int size = 1)
        => cache.Set(key, value, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = ttl,
            Size = size,
        });
}

Rồi cấm dùng IMemoryCache trực tiếp trong code nghiệp vụ.

14.2.2 — Hết hạn ​

csharp
var options = new MemoryCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),   // TRẦN cứng
    SlidingExpiration = TimeSpan.FromMinutes(5),                  // gia han khi doc
    Size = 1,
    Priority = CacheItemPriority.Normal,
};
Hành vi
AbsoluteExpirationHết hạn tại một thời điểm cụ thể
AbsoluteExpirationRelativeToNowHết hạn sau N thời gian kể từ khi tạo
SlidingExpirationHết hạn nếu không được đọc trong N thời gian

SlidingExpiration một mình là nguy hiểm:

csharp
// Entry được đọc mỗi phút -> KHÔNG BAO GIỜ hết hạn
new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) };

Khoá nóng được truy cập liên tục sẽ giữ dữ liệu cũ mãi mãi. Luôn kèm absolute expiration làm trần:

csharp
new MemoryCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromMinutes(5),                 // dọn dẹp entry nguội
    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),     // TRẦN cứng
    Size = 1,
};

Priority quyết định thứ tự bị dọn khi cache đầy:

csharp
Priority = CacheItemPriority.NeverRemove;    // chỉ bị xoá khi hết hạn
Priority = CacheItemPriority.High;
Priority = CacheItemPriority.Normal;         // mặc định
Priority = CacheItemPriority.Low;            // dọn đầu tiên

NeverRemove dùng rất hạn chế — nhiều entry như vậy làm SizeLimit mất tác dụng.

Chi tiết về thời điểm hết hạn: IMemoryCache không có bộ hẹn giờ chủ động cho từng entry. Entry hết hạn được dọn khi ai đó truy cập nó, hoặc trong lần quét định kỳ theo ExpirationScanFrequency. Nghĩa là bộ nhớ có thể được giữ lâu hơn TTL — điều cần biết khi tính dung lượng.

Muốn biết entry bị dọn vì lý do gì:

csharp
options.RegisterPostEvictionCallback((key, value, reason, state) =>
    logger.LogDebug("Cache evicted {Key} vi {Reason}", key, reason));

reason là Expired, Capacity, Removed, Replaced hay TokenExpired. Thấy nhiều Capacity nghĩa là SizeLimit quá nhỏ.

14.2.3 — GetOrCreateAsync không chống stampede ​

csharp
public Task<List<string>> GetPermissionsAsync(int userId)
    => _cache.GetOrCreateAsync($"perm:{userId}", async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        entry.Size = 1;

        return await _db.UserPermissions
            .Where(p => p.UserId == userId)
            .Select(p => p.Code)
            .ToListAsync();
    })!;

Tiện, nhưng factory có thể chạy nhiều lần đồng thời — GetOrCreateAsync không khoá. Với 50 request cùng lúc sau khi hết hạn, đó là 50 truy vấn (bài 14.1).

csharp
// Chong stampede
public sealed class SafeCache(IMemoryCache cache)
{
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

    public async Task<T> GetOrCreateAsync<T>(
        string key, Func<CancellationToken, Task<T>> factory, TimeSpan ttl, CancellationToken ct)
    {
        if (cache.TryGetValue(key, out T? cached)) return cached!;

        var gate = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
        await gate.WaitAsync(ct);

        try
        {
            if (cache.TryGetValue(key, out cached)) return cached!;     // kiểm tra LẠI

            var value = await factory(ct);
            cache.Set(key, value, new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = ttl,
                Size = 1,
            });
            return value;
        }
        finally
        {
            gate.Release();
            if (gate.CurrentCount == 1) _locks.TryRemove(key, out _);   // don rac
        }
    }
}

Dòng dọn rác cuối quan trọng: không có nó, _locks lớn dần theo số khoá đã từng dùng — một rò rỉ bộ nhớ chậm nhưng chắc chắn.

Luồng của SafeCache.GetOrCreateAsync khi nhiều request cùng hỏi một khoá vừa hết hạn:

HybridCache của .NET 9 làm sẵn việc này và nên là lựa chọn đầu tiên nếu bạn dùng .NET 9 (bài 14.5).

14.2.4 — Đừng cache entity EF Core ​

csharp
// SAI — nhiều tầng vấn đề
var customer = await _cache.GetOrCreateAsync($"customer:{id}", async entry =>
{
    entry.Size = 1;
    return await _db.Customers.FirstOrDefaultAsync(c => c.Id == id);   // ENTITY
});

Bốn vấn đề:

  1. Entity còn tham chiếu tới DbContext qua lazy loader và change tracker. DbContext bị Dispose cuối request, nhưng cache giữ tham chiếu — rò rỉ toàn bộ context và mọi entity nó theo dõi.
  2. Cùng một instance được chia sẻ giữa mọi request. Một request sửa customer.Name, mọi request khác thấy thay đổi đó.
  3. Navigation property có thể chưa nạp, và truy cập chúng sau khi context chết sẽ ném exception.
  4. Entity nặng hơn DTO — mọi cột, kể cả cột bạn không dùng.
csharp
// DUNG — cache DTO bat bien
public sealed record CustomerDto(int Id, string Name, string Email);

var dto = await _cache.GetOrCreateAsync($"customer:{id}", async entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
    entry.Size = 1;

    return await _db.Customers
        .Where(c => c.Id == id)
        .Select(c => new CustomerDto(c.Id, c.Name, c.Email))     // projection
        .FirstOrDefaultAsync();
});

record bất biến đảm bảo không ai sửa được giá trị trong cache — vấn đề 2 biến mất về mặt thiết kế.

Với kiểu có thể thay đổi, cân nhắc lưu bản sao thay vì tham chiếu; nếu không, một request sửa đối tượng sẽ ảnh hưởng mọi request khác.

14.2.5 — Giới hạn của cache trong tiến trình ​

3 pod, moi pod cache RIENG
=> Ty le trung giam 3 lan (moi pod phai tu nap)
=> Xoá cache trên pod A KHÔNG ảnh hưởng pod B và C
=> Deploy là mất sạch cache -> đợt tải đột biến vào database

Ba hệ quả này quyết định khi nào IMemoryCache đủ:

Dùng IMemoryCache khiDùng cache phân tán khi
Dữ liệu giống nhau ở mọi instanceDữ liệu theo người dùng hoặc session
Ít thay đổi (cấu hình, danh mục)Cần xoá chính xác khi dữ liệu đổi
Một instanceNhiều instance
Tốc độ là ưu tiên tuyệt đốiTỷ lệ trúng là ưu tiên

Hàng đầu là tiêu chí thật: cache danh sách quốc gia trong bộ nhớ mỗi pod là hoàn toàn đúng — dữ liệu giống nhau, và mỗi pod tự nạp một lần không tốn gì.

Cache quyền của người dùng thì khác: người dùng có thể vào pod bất kỳ, và thu hồi quyền phải có hiệu lực trên mọi pod (bài 10.7).

Đợt tải sau deploy là vấn đề thật với cache lớn: mọi pod khởi động với cache rỗng và cùng nạp lại. Nếu đó là vấn đề, nạp trước những khoá quan trọng trong IHostedService.

14.2.6 — Giám sát ​

csharp
builder.Services.AddOpenTelemetry()
    .WithMetrics(m => m.AddMeter("Microsoft.Extensions.Caching.Memory"));

.NET 9 phát metric sẵn cho IMemoryCache. Với bản cũ hơn, tự đếm:

csharp
public async Task<T> GetOrCreateAsync<T>(string key, ...)
{
    if (cache.TryGetValue(key, out T? cached))
    {
        _hits.Add(1, new KeyValuePair<string, object?>("cache", name));
        return cached!;
    }

    _misses.Add(1, new KeyValuePair<string, object?>("cache", name));
    ...
}

Ba chỉ số cần theo dõi: tỷ lệ trúng, số entry hiện có, và lý do bị dọn (nhiều Capacity nghĩa là SizeLimit quá nhỏ).

Không có ba chỉ số này, bạn không biết cache đang giúp hay chỉ đang tốn bộ nhớ.

14.2.7 — Rà lại code của bạn ​

Danh sách rà soát IMemoryCache

  • SizeLimit được đặt, không để cache lớn vô hạn.
  • Mọi entry đều khai Size; việc set cache được bọc sau một lớp tiện ích.
  • SlidingExpiration luôn kèm AbsoluteExpiration làm trần.
  • Có cơ chế chống stampede cho khoá lưu lượng cao.
  • SemaphoreSlim theo khoá được dọn sau khi dùng.
  • Không cache entity EF Core; cache DTO bất biến.
  • Kiểu lưu trong cache là bất biến, hoặc được sao chép khi trả ra.
  • Dữ liệu theo người dùng không nằm trong cache tiến trình nếu chạy nhiều instance.
  • Có nạp trước cache quan trọng nếu đợt tải sau deploy là vấn đề.
  • Theo dõi tỷ lệ trúng, số entry và lý do bị dọn.

Bài tập áp dụng ​

Bài 1 — Thiếu Size khi đã đặt SizeLimit ​

Đặt SizeLimit rồi gọi Set không khai Size. Ghi lại exception và thời điểm nó xảy ra.

Tiêu chí hoàn thành: bạn giải thích được vì sao SizeLimit dùng đơn vị do bạn tự định nghĩa, và chọn được đơn vị phù hợp.

Gợi ý và lời giải — Bài 1

Gợi ý. IMemoryCache lưu object. Nó biết một object chiếm bao nhiêu byte không?

Lời giải — đo thật, trên .NET 9.0.203 với Microsoft.Extensions.Caching.Memory 9.0.3:

csharp
var cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1024 });
cache.Set("k", "giá trị");
text
InvalidOperationException: Cache entry must specify a value for Size when SizeLimit is set.
csharp
cache.Set("k2", "giá trị", new MemoryCacheEntryOptions { Size = 1 });   // OK

Thời điểm exception xảy ra là điều đáng chú ý nhất. Nó không ném lúc AddMemoryCache, không ném lúc khởi động, không ném lúc build. Nó ném tại chính lời gọi Set — tức lúc chạy, ở một endpoint cụ thể, có thể là một endpoint hiếm dùng.

Hệ quả: bạn thêm SizeLimit để sửa một sự cố bộ nhớ, deploy, và ba ngày sau một endpoint ít dùng bắt đầu trả 500. Mọi thứ khác vẫn chạy bình thường, nên rất khó nối hai sự việc lại với nhau.

Đó là lý do khi thêm SizeLimit vào một ứng dụng đang chạy, phải rà mọi lời gọi Set và GetOrCreate trong cùng một lần thay đổi:

bash
grep -rn "\.Set(\|GetOrCreate" --include="*.cs" src/ | grep -i cache

Vì sao SizeLimit dùng đơn vị do bạn tự định nghĩa. Vì IMemoryCache lưu object và không có cách nào biết một object chiếm bao nhiêu byte.

.NET không cung cấp API đo kích thước sâu của một object. Đo được thì phải duyệt toàn bộ đồ thị tham chiếu — tốn kém, và vẫn không chính xác vì:

text
Một List<Lead> 100 phần tử chiếm bao nhiêu?
  - Bao gồm các chuỗi Name không? Chúng có thể được chia sẻ với nơi khác.
  - Bao gồm Customer mà mỗi Lead trỏ tới không? 100 Lead có thể trỏ tới 1 Customer.
  - Chuỗi được intern tính một lần hay 100 lần?

Không có câu trả lời đúng duy nhất. Nên IMemoryCache giao quyết định cho bạn: Size là một số nguyên trừu tượng trong một đơn vị do bạn chọn, và SizeLimit là tổng trong cùng đơn vị đó.

Ba cách chọn đơn vị:

1. Đơn vị "số mục" — đơn giản nhất, và đủ cho phần lớn trường hợp:

csharp
services.AddMemoryCache(o => o.SizeLimit = 10_000);   // tối đa 10.000 mục
csharp
cache.Set(khoa, giaTri, new MemoryCacheEntryOptions { Size = 1 });

Phù hợp khi các mục có kích thước tương đương nhau. Dễ hiểu, dễ giám sát.

2. Đơn vị "số phần tử" — khi cache chứa danh sách dài ngắn khác nhau:

csharp
cache.Set(khoa, danhSachLead, new MemoryCacheEntryOptions { Size = danhSachLead.Count });
csharp
services.AddMemoryCache(o => o.SizeLimit = 500_000);   // tổng 500.000 phần tử

Một danh sách 10.000 lead chiếm chỗ của 10.000 mục nhỏ — đúng với thực tế bộ nhớ hơn cách 1.

3. Đơn vị "byte ước lượng" — chính xác nhất, tốn công nhất:

csharp
var kichThuoc = JsonSerializer.SerializeToUtf8Bytes(giaTri).Length;
cache.Set(khoa, giaTri, new MemoryCacheEntryOptions { Size = kichThuoc });
csharp
services.AddMemoryCache(o => o.SizeLimit = 200 * 1024 * 1024);   // khoảng 200 MB

Cẩn thận: serialize chỉ để đo là chi phí thật trên mỗi lần ghi cache, và kích thước JSON không bằng kích thước trong bộ nhớ (object .NET thường lớn hơn JSON 2–3 lần). Chỉ dùng cách này khi kích thước các mục chênh lệch rất lớn.

Quan trọng: chọn một đơn vị và dùng nhất quán. Trộn hai đơn vị trong cùng một MemoryCache khiến SizeLimit trở nên vô nghĩa:

csharp
cache.Set("a", x, new MemoryCacheEntryOptions { Size = 1 });        // đơn vị: mục
cache.Set("b", y, new MemoryCacheEntryOptions { Size = 52_400 });   // đơn vị: byte
// -> mục "b" chiếm hết hạn mức, mọi thứ khác bị đẩy ra ngay lập tức

Cách phòng: bọc IMemoryCache lại và không cho tầng gọi tự đặt Size:

csharp
public void Luu<T>(string khoa, T giaTri, TimeSpan ttl, int soPhanTu = 1)
    => _cache.Set(khoa, giaTri, new MemoryCacheEntryOptions
    {
        Size = soPhanTu,                              // một đơn vị duy nhất, do lớp này quyết định
        AbsoluteExpirationRelativeToNow = ttl,
    });

Vì sao thiếu SizeLimit là rò rỉ bộ nhớ. Mặc định SizeLimit là null, nghĩa là không giới hạn:

csharp
services.AddMemoryCache();                            // KHÔNG có giới hạn nào
csharp
_cache.Set($"lead:{id}", lead, TimeSpan.FromHours(1));

Với 200.000 lead được xem trong một giờ, cache giữ 200.000 entity. IMemoryCache không tự đẩy mục ra vì thiếu bộ nhớ — nó chỉ xoá khi mục hết hạn, hoặc khi vượt SizeLimit (mà bạn chưa đặt).

Triệu chứng điển hình: bộ nhớ tăng đều, GC gen 2 chạy ngày càng nhiều, và cuối cùng là OutOfMemoryException hoặc pod bị Kubernetes giết vì vượt memory limit. Và nó xảy ra sau vài giờ hoặc vài ngày, nên không bao giờ lộ ra khi test.

Bộ ba cấu hình an toàn tối thiểu:

csharp
services.AddMemoryCache(o =>
{
    o.SizeLimit = 10_000;                              // 1. luôn đặt giới hạn
    o.CompactionPercentage = 0.25;                     // 2. khi đầy, đẩy ra 25%
    o.ExpirationScanFrequency = TimeSpan.FromMinutes(1); // 3. quét mục hết hạn mỗi phút
});

Và một metric để biết cache có đang chạm trần không:

csharp
var thongKe = ((MemoryCache)_cache).GetCurrentStatistics();
_logger.LogInformation("Cache: {Size}/{Limit} mục, trúng {Hit}, trượt {Miss}",
    thongKe?.CurrentEntryCount, 10_000, thongKe?.TotalHits, thongKe?.TotalMisses);

Nếu CurrentEntryCount thường xuyên sát SizeLimit, cache đang liên tục đẩy mục ra — và tỷ lệ trúng của bạn đang bị giới hạn bởi bộ nhớ, không phải bởi mẫu truy cập.


Bài 2 — Sliding expiration sống mãi ​

Cache một giá trị với SlidingExpiration = 5s và đọc nó mỗi 3 giây trong 5 phút. Xác nhận nó không bao giờ hết hạn. Thêm absolute expiration và kiểm chứng.

Tiêu chí hoàn thành: bạn nêu được hai hậu quả cụ thể của việc một mục cache sống mãi, và biết cách chọn cặp giá trị sliding và absolute.

Gợi ý và lời giải — Bài 2

Gợi ý. "Sliding" nghĩa là gì? Mỗi lần đọc làm gì với thời hạn?

Lời giải — đo thật (rút ngắn còn sliding 1 giây, đọc mỗi 0,6 giây):

csharp
cache.Set("k", "giá trị", new MemoryCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromSeconds(1),
});

for (var i = 1; i <= 10; i++)
{
    await Task.Delay(600);
    Console.WriteLine($"t={i * 0.6:F1}s  {(cache.Get<string>("k") is null ? "HẾT HẠN" : "vẫn còn")}");
}
text
t=0.6s  vẫn còn
t=1.2s  vẫn còn
t=1.8s  vẫn còn
t=2.4s  vẫn còn
t=3.0s  vẫn còn
t=3.6s  vẫn còn
t=4.2s  vẫn còn
t=4.8s  vẫn còn
t=5.4s  vẫn còn
t=6.0s  vẫn còn

Sliding 1 giây, nhưng sau 6 giây mục vẫn còn — và sẽ còn mãi chừng nào còn có người đọc.

Thêm absolute expiration:

csharp
cache.Set("k", "giá trị", new MemoryCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromSeconds(1),
    AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(2),
});
text
t=0.6s  vẫn còn
t=1.2s  vẫn còn
t=1.8s  vẫn còn
t=2.4s  HẾT HẠN

Vì sao. SlidingExpiration nghĩa là "hết hạn sau N khoảng thời gian không được truy cập". Mỗi lần đọc đặt lại đồng hồ về N:

text
t=0,0  Set, đồng hồ = 1,0s
t=0,6  đọc  -> đồng hồ đặt lại về 1,0s  (còn 0,4s nữa mới hết)
t=1,2  đọc  -> đồng hồ đặt lại về 1,0s
t=1,8  đọc  -> đồng hồ đặt lại về 1,0s
...

Chừng nào khoảng cách giữa hai lần đọc còn ngắn hơn SlidingExpiration, đồng hồ không bao giờ chạy hết. Đây không phải bug — đó chính xác là điều SlidingExpiration được thiết kế để làm.

AbsoluteExpirationRelativeToNow là một đồng hồ thứ hai, không bao giờ được đặt lại. Mục hết hạn khi một trong hai đồng hồ chạy hết:

text
SlidingExpiration:  hết hạn nếu KHÔNG ai đọc trong N
AbsoluteExpiration: hết hạn sau M, bất kể có ai đọc hay không
Kết hợp:            hết hạn ở cái nào đến trước

Hai hậu quả cụ thể của một mục sống mãi:

1. Dữ liệu cũ tồn tại vô thời hạn. Đây là hậu quả nghiêm trọng hơn, và nó có một hình dạng rất đặc trưng:

csharp
_cache.Set($"cau-hinh:{tenantId}", cauHinh, new MemoryCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromMinutes(10),      // không có absolute
});

Cấu hình được đọc ở mọi request. Với một tenant có traffic liên tục, khoảng cách giữa hai lần đọc luôn dưới 10 phút, nên mục không bao giờ hết hạn. Admin đổi cấu hình, và thay đổi không có hiệu lực — cho tới lần khởi động lại tiếp theo.

Điều làm nó khó chẩn đoán: tenant ít traffic thì hoạt động đúng. Với họ, cache hết hạn sau 10 phút không hoạt động và cấu hình mới được nạp. Nên báo cáo lỗi đến dưới dạng "chỉ khách hàng lớn bị", và không ai nghĩ tới cache.

2. Bộ nhớ không bao giờ được giải phóng cho những khoá còn được chạm tới. Kết hợp với thiếu SizeLimit ở bài 1, đây là công thức của rò rỉ bộ nhớ:

text
200.000 lead, mỗi lead được xem vài lần mỗi ngày
SlidingExpiration = 30 phút, không có absolute
-> bất kỳ lead nào được xem 2 lần cách nhau dưới 30 phút sẽ ở lại
-> cache lớn dần và không bao giờ co lại

Cách chọn cặp giá trị:

csharp
new MemoryCacheEntryOptions
{
    SlidingExpiration               = TimeSpan.FromMinutes(5),    // dọn mục nguội
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),   // trần cứng
}

Hai giá trị trả lời hai câu hỏi khác nhau:

Câu hỏiQuyết định bởi
SlidingExpirationBao lâu không dùng thì nên dọn?Bộ nhớ và mẫu truy cập
AbsoluteExpirationDữ liệu cũ tối đa bao lâu thì chấp nhận được?Nghiệp vụ

Câu thứ hai là câu quan trọng. Với cấu hình mà admin sửa và mong thấy hiệu lực trong 5 phút, absolute phải là 5 phút — bất kể sliding bằng bao nhiêu.

Tỷ lệ thường dùng: absolute gấp 3 đến 10 lần sliding.

text
Sliding 1 phút,  absolute 10 phút   -> dữ liệu truy cập liên tục, cần tươi
Sliding 5 phút,  absolute 30 phút   -> mặc định hợp lý cho phần lớn trường hợp
Sliding 30 phút, absolute 4 giờ     -> dữ liệu hiếm đổi, ví dụ danh mục

Absolute quá sát sliding thì sliding mất tác dụng. Absolute quá xa thì dữ liệu cũ tồn tại lâu hơn nghiệp vụ cho phép.

Quy tắc thực dụng:

Luôn đặt AbsoluteExpiration. SlidingExpiration là tuỳ chọn.

Chỉ có absolute là đủ dùng cho phần lớn trường hợp, và nó là hành vi dễ đoán nhất — mọi mục sống đúng N phút, không phụ thuộc vào ai đọc nó lúc nào. Thêm sliding chỉ khi bạn thật sự cần dọn sớm những mục nguội để tiết kiệm bộ nhớ.

Chỉ có sliding mà không có absolute là cấu hình gần như luôn sai, vì nó đánh đổi tính đúng đắn lấy một chút bộ nhớ.

Bọc lại để không ai quên:

csharp
public void Luu<T>(string khoa, T giaTri, TimeSpan? sliding = null, TimeSpan? absolute = null)
{
    var options = new MemoryCacheEntryOptions
    {
        Size = 1,
        // Mặc định 30 phút — không bao giờ để null
        AbsoluteExpirationRelativeToNow = absolute ?? TimeSpan.FromMinutes(30),
    };
    if (sliding is not null) options.SlidingExpiration = sliding;

    _cache.Set(khoa, giaTri, options);
}

Bài 3 — Cache entity EF Core làm rò rỉ DbContext ​

Cache một entity EF Core, đo bộ nhớ sau 1.000 request, và dùng dotnet-gcdump để xác nhận các DbContext vẫn còn sống trong heap.

Tiêu chí hoàn thành: bạn vẽ được chuỗi tham chiếu từ mục cache tới DbContext, và nêu được vì sao vấn đề không chỉ là bộ nhớ.

Gợi ý và lời giải — Bài 3

Gợi ý. Entity được tracking giữ tham chiếu tới cái gì?

Lời giải — bản có lỗi:

csharp
public async Task<Lead> LayLeadAsync(int id, CancellationToken ct)
{
    return await _cache.GetOrCreateAsync($"lead:{id}", async e =>
    {
        e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
        return await _db.Leads.FirstAsync(l => l.Id == id, ct);   // entity CÓ tracking
    });
}
bash
dotnet-counters monitor --process-id <pid> --counters System.Runtime[gc-heap-size]
text
Sau   100 request:   gc-heap-size    142 MB
Sau   500 request:   gc-heap-size    418 MB
Sau 1.000 request:   gc-heap-size    790 MB
bash
dotnet-gcdump collect --process-id <pid> --output sau-1000.gcdump
text
Type                                          Count      Size (bytes)
CrmDbContext                                    987       412.008.216
Microsoft.EntityFrameworkCore.ChangeTracking
    .Internal.StateManager                      987        86.400.312
Microsoft.Data.SqlClient.SqlConnection           987        12.340.104
Lead                                          1.000         1.240.000

987 DbContext vẫn sống, trong khi lẽ ra mỗi cái phải được giải phóng khi request kết thúc. Bản thân các Lead chỉ chiếm 1,2 MB — 412 MB còn lại là thứ chúng kéo theo.

Chuỗi tham chiếu:

text
IMemoryCache (singleton, sống suốt vòng đời tiến trình)
   └─> mục "lead:123"
        └─> Lead (entity ĐANG ĐƯỢC TRACKING)
             └─> .Customer  (navigation, cũng được tracking)
             │    └─> .Contacts, .Orders...   (cả đồ thị quan hệ)
             └─> EntityEntry trong StateManager
                  └─> StateManager
                       └─> CrmDbContext
                            ├─> DbConnection  (giữ kết nối trong pool)
                            ├─> Model         (chia sẻ, không sao)
                            └─> mọi entity khác đã nạp trong request đó

Mắt xích quyết định là entity được tracking giữ tham chiếu ngược tới DbContext qua change tracker. IMemoryCache là singleton, nên chừng nào mục cache còn sống, cả chuỗi phía dưới còn sống — kể cả DbContext mà vòng đời Scoped lẽ ra đã kết thúc.

Chú ý dòng cuối trong sơ đồ: DbContext còn giữ mọi entity khác đã được nạp trong request đó. Cache một Lead từ một request cũng nạp 500 dòng báo cáo, và 500 dòng đó ở lại trong heap.

Bản sửa — ba mức, từ tối thiểu đến tốt nhất:

1. Tối thiểu — AsNoTracking:

csharp
return await _db.Leads.AsNoTracking().FirstAsync(l => l.Id == id, ct);

Không có EntityEntry, nên không có đường từ Lead ngược về DbContext. Cắt chuỗi ngay ở mắt xích quan trọng nhất.

Vẫn còn một vấn đề: entity có navigation property, và nếu chúng được nạp, cache giữ cả đồ thị quan hệ — có thể lớn hơn nhiều so với thứ bạn cần.

2. Tốt hơn — cache DTO, không cache entity:

csharp
public sealed record LeadCacheDto(int Id, string Name, decimal Value, string Status);

return await _cache.GetOrCreateAsync($"lead:{id}", async e =>
{
    e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
    e.Size = 1;
    return await _db.Leads
        .AsNoTracking()
        .Where(l => l.Id == id)
        .Select(l => new LeadCacheDto(l.Id, l.Name, l.Value, l.Status))
        .FirstOrDefaultAsync(ct);
});

DTO là một record phẳng, không có navigation, không có tham chiếu tới bất cứ thứ gì của EF Core. Nó cũng nhỏ hơn nhiều, nên cache chứa được nhiều mục hơn trong cùng bộ nhớ.

3. Tốt nhất — HybridCache, vì nó serialize nên vấn đề không tồn tại được:

csharp
var lead = await _cache.GetOrCreateAsync(
    $"lead:{id}",
    async token => await _db.Leads.AsNoTracking()
        .Where(l => l.Id == id)
        .Select(l => new LeadCacheDto(l.Id, l.Name, l.Value, l.Status))
        .FirstOrDefaultAsync(token),
    cancellationToken: ct);

HybridCache serialize giá trị trước khi lưu ở tầng L2. Một object đã được serialize không giữ tham chiếu tới gì cả — vấn đề bị loại bỏ về mặt cấu trúc, không phải nhờ kỷ luật.

Vì sao vấn đề không chỉ là bộ nhớ — đây là phần chính của bài. Ba hậu quả khác, và cả ba đều tệ hơn việc tốn RAM:

1. Dữ liệu sai do identity map cũ. Entity trong cache mang theo OriginalValues từ lúc nó được nạp. Nếu code nào đó gắn nó vào một DbContext mới rồi lưu, EF Core so sánh với một quá khứ đã xa:

csharp
var lead = await LayLeadAsync(123, ct);      // từ cache, nạp 25 phút trước
lead.Status = "Won";
_db.Update(lead);                            // OriginalValues của 25 phút trước
await _db.SaveChangesAsync(ct);              // ghi đè mọi thay đổi trong 25 phút đó

Đây là lost update ở bài 13.13, nhưng với khoảng thời gian tính bằng chục phút thay vì vài giây.

2. Kết nối database bị giữ. DbContext sống giữ DbConnection, và connection pool mặc định chỉ có 100 kết nối. Với 987 DbContext còn sống, pool có thể cạn:

text
Timeout expired. The timeout period elapsed prior to obtaining a connection
from the pool. This may have occurred because all pooled connections were in use.

Thông điệp lỗi này không nhắc gì tới cache, nên nó dẫn người điều tra đi sai hướng — thường là đi tăng Max Pool Size, khiến vấn đề tạm biến mất rồi quay lại lớn hơn.

3. Lỗi lúc chạy khi truy cập navigation chưa nạp. Với lazy loading, entity trong cache giữ proxy trỏ tới một DbContext đã Dispose:

csharp
var lead = await LayLeadAsync(123, ct);
Console.WriteLine(lead.Customer.Name);
text
ObjectDisposedException: Cannot access a disposed context instance.

Lỗi xuất hiện không ổn định — chỉ khi cache trúng, và chỉ khi DbContext gốc đã bị dọn. Trên máy dev với một request tại một thời điểm, nó gần như không bao giờ xảy ra.

Quy tắc rút ra:

Không bao giờ cache entity EF Core. Cache DTO.

Và một kiểm tra tự động, vì quy tắc nào cũng sẽ có người quên:

csharp
[Fact]
public void Khong_duoc_cache_entity_EF_Core()
{
    var kieuEntity = typeof(CrmDbContext).GetProperties()
        .Where(p => p.PropertyType.IsGenericType &&
                    p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>))
        .Select(p => p.PropertyType.GetGenericArguments()[0])
        .ToHashSet();

    var viPham = typeof(Program).Assembly.GetTypes()
        .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic
                                    | BindingFlags.Instance | BindingFlags.Static))
        .Where(m => m.Name.Contains("Cache"))
        .Where(m => KieuTraVeChua(m.ReturnType, kieuEntity))
        .Select(m => $"{m.DeclaringType!.Name}.{m.Name}")
        .ToList();

    viPham.Should().BeEmpty("cache entity EF Core giữ cả DbContext trong bộ nhớ");
}

Và giám sát trên production:

text
Cảnh báo khi số DbContext còn sống > 2 × số request đồng thời

Chỉ số này bắt được không chỉ lỗi cache mà cả mọi trường hợp DbContext bị giữ ngoài vòng đời của nó — biến static, closure trong sự kiện, hay một Task chạy nền quên await.

Tự kiểm tra ​

Vì sao phải đặt SizeLimit?

Không có nó, cache chỉ bị dọn khi GC báo áp lực bộ nhớ, và trong container có giới hạn cứng thì tiến trình thường bị OOM kill trước khi điều đó xảy ra. Nhưng khi đã đặt SizeLimit thì mọi entry bắt buộc phải khai Size, nếu không Set ném exception lúc chạy.

SlidingExpiration có vấn đề gì khi dùng một mình?

Mỗi lần đọc lại gia hạn, nên khoá được truy cập liên tục sẽ không bao giờ hết hạn và giữ dữ liệu cũ mãi mãi. Luôn kèm absolute expiration làm trần cứng; sliding lo dọn entry nguội, absolute lo giới hạn độ cũ.

GetOrCreateAsync có chống stampede không?

Không. Nó không khoá, nên nhiều request đồng thời cùng thấy miss sẽ cùng chạy factory. Với 50 request sau khi hết hạn, đó là 50 truy vấn. Phải tự khoá theo khoá với kiểm tra lại bên trong, hoặc dùng HybridCache của .NET 9.

Vì sao không cache entity EF Core?

Bốn lý do. Entity còn tham chiếu tới DbContext nên cache giữ sống cả context đã bị dispose. Cùng một instance được chia sẻ giữa mọi request nên ai sửa cũng ảnh hưởng người khác. Navigation property chưa nạp sẽ ném exception. Và entity nặng hơn DTO. Cache DTO bất biến thay vì entity.

Khi nào IMemoryCache là đủ?

Khi dữ liệu giống nhau ở mọi instance và ít thay đổi, ví dụ danh mục hay cấu hình. Khi đó mỗi pod tự nạp một lần không tốn gì. Dữ liệu theo người dùng hoặc cần xoá chính xác khi thay đổi thì phải dùng cache phân tán.

Entry hết hạn có được dọn ngay không?

Không. IMemoryCache không có bộ hẹn giờ chủ động cho từng entry; nó dọn khi ai đó truy cập entry đó hoặc trong lần quét định kỳ theo ExpirationScanFrequency. Nghĩa là bộ nhớ có thể được giữ lâu hơn TTL.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. SizeLimit là bắt buộc — và nó kéo theo mọi entry phải khai Size.
  2. SlidingExpiration một mình khiến entry nóng sống mãi. Luôn có trần absolute.
  3. Cache DTO bất biến, không cache entity EF Core.

Tham khảo ​

Điều hướng ​

Bài liên quan ​

Phát hành theo giấy phép MIT.