Cache Invalidation trong .NET

Cách giữ cho dữ liệu đệm luôn mới

Đệm (caching) là một trong những cách phổ biến nhất để cải thiện hiệu suất ứng dụng.

Thay vì đọc dữ liệu từ cơ sở dữ liệu mỗi lần, chúng ta lưu trữ dữ liệu được sử dụng thường xuyên trong bộ nhớ đệm như Redis hoặc MemoryCache.

Điều này giúp ứng dụng của chúng ta nhanh hơn và giảm tải cho cơ sở dữ liệu.

Nhưng đệm tạo ra một vấn đề quan trọng:

Điều gì xảy ra khi dữ liệu trong cơ sở dữ liệu thay đổi?

Đây là lúc cache invalidation phát huy tác dụng.

Cache Invalidation là gì?

Cache invalidation có nghĩa là xóa hoặc cập nhật dữ liệu cũ trong bộ nhớ đệm khi dữ liệu gốc thay đổi.

Ví dụ, tưởng tượng chúng ta có một sản phẩm:

ID sản phẩm: 123
Giá: $100

Ứng dụng lưu trữ nó trong bộ nhớ đệm:

product:123 → Giá: $100

Sau đó, quản trị viên thay đổi giá:

Cơ sở dữ liệu:
Sản phẩm 123 → Giá: $80

Nhưng bộ nhớ đệm vẫn chứa:

Bộ nhớ đệm:
product:123 → Giá: $100

Bây giờ người dùng có thể tiếp tục nhìn thấy giá cũ.

Điều này được gọi là dữ liệu đệm lỗi thời.

Tại sao Cache Invalidation quan trọng?

Việc không thực hiện invalidation đúng cách có thể khiến người dùng thấy thông tin lỗi thời.

Ví dụ:

  • Giá sản phẩm đã thay đổi từ $100 thành $80
  • Hồ sơ người dùng đã được cập nhật
  • Trạng thái đơn hàng đã thay đổi
  • Số lượng tồn kho đã được cập nhật
  • Một sản phẩm đã bị xóa

Nhưng bộ nhớ đệm có thể vẫn chứa giá trị cũ.

Điều này có thể khiến thông tin sai lệch được hiển thị cho người dùng.

Một luồng đệm đơn giản

Một ứng dụng điển hình có thể hoạt động như sau:

Yêu cầu người dùng
↓
Kiểm tra bộ nhớ đệm
↓
Có trong đệm?
↙ ↘
Có Không
↓ ↓
Trả về dữ liệu Cơ sở dữ liệu
↓
Lưu vào đệm
↓
Trả về dữ liệu

Điều này hoạt động tốt cho đến khi dữ liệu cơ sở dữ liệu thay đổi.

Đó là lúc chúng ta cần cache invalidation.

Ví dụ: Product API

Hãy nói rằng chúng ta có một API sản phẩm.

Endpoint GET đọc sản phẩm từ bộ nhớ đệm trước tiên.

public async Task<Product?> GetProductAsync(int id)
{
    var cacheKey = $"product:{id}";
    var product = await _cache.GetStringAsync(cacheKey);

    if (product != null)
    {
        return JsonSerializer.Deserialize<Product>(product);
    }

    product = await _repository.GetProductAsync(id);

    if (product != null)
    {
        var json = JsonSerializer.Serialize(product);
        await _cache.SetStringAsync(
            cacheKey,
            json,
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    TimeSpan.FromMinutes(10)
            });
    }

    return product;
}

Yêu cầu đầu tiên lấy sản phẩm từ cơ sở dữ liệu và lưu vào bộ nhớ đệm.

Các yêu cầu tiếp theo có thể lấy sản phẩm trực tiếp từ bộ nhớ đệm.

Vấn đề khi cập nhật dữ liệu

Bây giờ hãy tưởng tượng quản trị viên cập nhật giá sản phẩm.

Giá cũ: $100
Giá mới: $80

Chúng ta cập nhật cơ sở dữ liệu:

await _repository.UpdateProductAsync(product);

Nhưng nếu chúng ta không cập nhật bộ nhớ đệm, nó vẫn chứa:

product:123 → $100

Cơ sở dữ liệu nói $80, nhưng bộ nhớ đệm nói $100.

Đây là vấn đề.

Giải pháp: Hủy bộ nhớ đệm

Sau khi cập nhật cơ sở dữ liệu, hãy xóa sản phẩm đã được đệm.

public async Task UpdateProductAsync(Product product)
{
    await _repository.UpdateProductAsync(product);

    var cacheKey = $"product:{product.Id}";
    await _cache.RemoveAsync(cacheKey);
}

Bây giờ luồng trở thành:

Cập nhật sản phẩm
↓
Cập nhật cơ sở dữ liệu
↓
Xóa bộ nhớ đệm
↓
Yêu cầu tiếp theo
↓
Đọc dữ liệu mới nhất
↓
Lưu dữ liệu mới vào bộ nhớ đệm

Yêu cầu tiếp theo sẽ lấy sản phẩm mới nhất từ cơ sở dữ liệu và lưu lại vào bộ nhớ đệm.

Mẫu Cache-Aside

Cách tiếp cận này thường được gọi là Mẫu Cache-Aside.

Ứng dụng kiểm soát bộ nhớ đệm.

Để đọc:

Kiểm tra bộ nhớ đệm
↓
Tìm thấy?
┌──┴──┐
Có Không
↓ ↓
Trả về Cơ sở dữ liệu
↓
Lưu vào đệm
↓
Trả về

Để cập nhật:

Cập nhật cơ sở dữ liệu
↓
Hủy bộ nhớ đệm

Đây là một trong những mẫu đệm đơn giản và phổ biến nhất.

Vậy còn xóa dữ liệu?

Nguyên tắc tương tự áp dụng khi xóa sản phẩm.

public async Task DeleteProductAsync(int id)
{
    await _repository.DeleteProductAsync(id);

    var cacheKey = $"product:{id}";
    await _cache.RemoveAsync(cacheKey);
}

Chúng ta xóa dữ liệu khỏi cơ sở dữ liệu trước, sau đó xóa khỏi bộ nhớ đệm.

Còn cập nhật bộ nhớ đệm thay vì xóa?

Thay vì xóa bộ nhớ đệm, chúng ta cũng có thể cập nhật nó.

Ví dụ:

await _repository.UpdateProductAsync(product);

var cacheKey = $"product:{product.Id}";
var json = JsonSerializer.Serialize(product);

await _cache.SetStringAsync(
    cacheKey,
    json,
    new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMinutes(10)
    });

Bây giờ cả cơ sở dữ liệu và bộ nhớ đệm đều chứa giá trị mới.

Tuy nhiên, cache invalidation thường đơn giản và an toàn hơn.

Tại sao?

Bởi vì yêu cầu tiếp theo có thể tải dữ liệu mới nhất từ cơ sở dữ liệu và xây dựng lại bộ nhớ đệm.

TTL: Một lớp bảo vệ nữa

Các mục trong bộ nhớ đệm cũng có thể có TTL (Time To Live – Thời gian tồn tại).

Ví dụ:

var options = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow =
        TimeSpan.FromMinutes(10)
};

await _cache.SetStringAsync(
    cacheKey,
    json,
    options);

Sau 10 phút, dữ liệu đệm sẽ tự động hết hạn.

Điều này giúp ngăn dữ liệu tồn tại vĩnh viễn trong bộ nhớ đệm.

Nhưng hãy nhớ:

TTL một mình không đảm bảo dữ liệu mới.

Ví dụ, nếu giá sản phẩm thay đổi một phút sau khi được đệm, người dùng có thể vẫn thấy giá cũ trong chín phút nữa.

Đó là lý do tại sao invalidation rõ ràng có ích khi dữ liệu thay đổi.

Ví dụ Redis

Trong các ứng dụng .NET phân tán, Redis thường được sử dụng làm bộ nhớ đệm chia sẻ.

Nguyên tắc invalidation tương tự áp dụng.

Ví dụ:

await _distributedCache.RemoveAsync(
    $"product:{product.Id}");

Điều này xóa sản phẩm khỏi Redis.

Yêu cầu tiếp theo sẽ tải sản phẩm mới nhất và lưu lại vào bộ nhớ đệm.

Một ví dụ thực tế

Hãy tưởng tượng một ứng dụng thương mại điện tử.

Một sản phẩm có:

Sản phẩm: iPhone
Giá: $1,000
Tồn kho: 20

Bộ nhớ đệm chứa:

product:100
Giá: $1,000
Tồn kho: 20

Một quản trị viên thay đổi tồn kho:

Tồn kho: 20 → 15

Ứng dụng nên:

Cập nhật cơ sở dữ liệu
↓
Hủy product:100
↓
Yêu cầu tiếp theo
↓
Đọc sản phẩm từ cơ sở dữ liệu
↓
Đệm dữ liệu mới

Bây giờ người dùng thấy:

Giá: $1,000
Tồn kho: 15

thay vì giá trị tồn kho cũ.

Quy tắc quan trọng: Hủy các mục đệm liên quan

Đôi khi một thay đổi cơ sở dữ liệu ảnh hưởng đến nhiều mục đệm.

Ví dụ, thay đổi một sản phẩm có thể ảnh hưởng đến:

  • product:123
  • products:category:phones
  • products:featured
  • products:search:iphone

Nếu chúng ta chỉ hủy:

product:123

các kết quả đệm khác có thể vẫn chứa thông tin cũ.

Vì vậy chúng ta cần suy nghĩ về những khóa đệm nào bị ảnh hưởng bởi thay đổi.

Các phương pháp hay nhất

1. Sử dụng khóa đệm rõ ràng

Sử dụng các khóa có thể dự đoán:

product:123
user:456
order:789

Điều này giúp việc invalidation dễ dàng hơn.

2. Hủy sau khi cập nhật cơ sở dữ liệu thành công

Một cách tiếp cận tốt là:

Cập nhật cơ sở dữ liệu
↓
Thành công?
↓
Hủy bộ nhớ đệm

Đừng xóa bộ nhớ đệm nếu việc cập nhật cơ sở dữ liệu thất bại.

3. Luôn xem xét TTL

Ngay cả với invalidation rõ ràng, TTL cung cấp thêm cơ chế bảo vệ.

4. Giữ dữ liệu đệm nhỏ

Đừng đệm dữ liệu không cần thiết.

Đệm thông tin được yêu cầu thường xuyên và có chi phí lấy cao.

5. Nghĩ về dữ liệu liên quan

Một cập nhật cơ sở dữ liệu duy nhất có thể ảnh hưởng đến nhiều đối tượng được đệm.

Hãy đảm bảo tất cả các mục đệm bị ảnh hưởng được xem xét.

Kết luận

Cache invalidation không chỉ là xóa dữ liệu đệm. Nó là đảm bảo người dùng nhận được thông tin mới nhất, chính xác trong khi vẫn được hưởng lợi từ hiệu suất đệm.

Hãy bắt đầu với cách tiếp cận đơn giản Cache-Aside + Invalidated + TTL, và giới thiệu các chiến lược nâng cao hơn chỉ khi ứng dụng của bạn cần.