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

14.4 — 3. IDistributedCache và Redis

Tóm tắt

Cache phân tán đổi tốc độ lấy tính nhất quán giữa các instance: một lần đọc mất khoảng 1ms thay vì 100 nano giây, và mọi đối tượng phải serialize — chi phí thường lớn hơn cả lượt đi về mạng. Điều đó thay đổi tính toán: cache một truy vấn 2ms vào Redis là làm chậm hệ thống. IDistributedCache là trừu tượng cố ý tối giản — nó chỉ có Get, Set, Remove, Refresh, nên bạn mất gần như mọi thứ khiến Redis mạnh: tập hợp, bộ đếm nguyên tử, pub/sub, SCAN. Và hai điều về vận hành: Redis chết không được làm sập ứng dụng, và KEYS * trên production là lệnh làm treo cả server.

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

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

  • Quyết định khi nào cache phân tán đáng giá.
  • Giảm chi phí serialize.
  • Thiết kế khoá cache có cấu trúc.
  • Xử lý khi Redis không khả dụng.
  • Biết khi nào bỏ IDistributedCache để dùng Redis trực tiếp.

Nội dung bài học​

14.4.1 — Chi phí thật​

IMemoryCache:      ~100 nano giay   (doc thang tu heap)
Redis cùng mạng: ~0,5-1 mili giây (mạng + serialize + deserialize)
Truy van database: ~2-50 mili giay

Redis nhanh hơn database nhưng chậm hơn bộ nhớ khoảng 10.000 lần. Hệ quả:

  • Cache truy vấn 50ms vào Redis: tiết kiệm lớn.
  • Cache truy vấn 2ms vào Redis: chậm hơn khi tính cả serialize.

Và serialize thường đắt hơn mạng. Một đối tượng 100KB mất vài mili giây để serialize và deserialize — nhiều hơn lượt đi về Redis.

Nguyên tắc: cache vào Redis những thứ đắt để tạo ra, không phải những thứ chỉ hơi chậm.

14.4.2 — Cấu hình​

builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "crm:"; // tiền tố cho MỌI khoá
});

InstanceName là bắt buộc khi nhiều ứng dụng dùng chung một Redis — không có nó, khoá user:42 của ứng dụng này va chạm với ứng dụng kia.

Cấu hình đầy đủ hơn cho production:

options.ConfigurationOptions = new ConfigurationOptions
{
EndPoints = { "redis-1:6379", "redis-2:6379" },
Password = redisPassword,
Ssl = true,
AbortOnConnectFail = false, // KHỞI ĐỘNG được khi Redis chưa sẵn sàng
ConnectRetry = 5,
ConnectTimeout = 5000,
SyncTimeout = 1000, // thất bại NHANH, đừng chờ lâu
DefaultDatabase = 0,
};

AbortOnConnectFail = false cho ứng dụng khởi động khi Redis chưa lên — quan trọng với thứ tự khởi động trong Kubernetes.

SyncTimeout thấp là có chủ đích: nếu Redis chậm, thất bại nhanh và đi thẳng database tốt hơn là chờ.

14.4.3 — Serialize​

IDistributedCache chỉ làm việc với byte[]:

public static class DistributedCacheExtensions
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

public static async Task SetAsync<T>(
this IDistributedCache cache, string key, T value,
TimeSpan ttl, CancellationToken ct = default)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, Options);

await cache.SetAsync(key, bytes,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }, ct);
}

public static async Task<T?> GetAsync<T>(
this IDistributedCache cache, string key, CancellationToken ct = default)
{
var bytes = await cache.GetAsync(key, ct);
return bytes is null ? default : JsonSerializer.Deserialize<T>(bytes, Options);
}
}

SerializeToUtf8Bytes nhanh hơn Serialize rồi Encoding.UTF8.GetBytes — nó bỏ qua bước tạo chuỗi trung gian.

Ba lưu ý về serialize:

  1. Kiểu phải serialize được. Entity EF Core có navigation vòng tròn sẽ ném exception hoặc sinh JSON khổng lồ. Cache DTO (bài 14.3).
  2. Đổi hình dạng DTO phá cache cũ. Thêm trường thì thường ổn; xoá hoặc đổi kiểu thì deserialize ném exception. Đưa phiên bản vào khoá: customer:v2:42.
  3. JSON không phải lựa chọn duy nhất. MessagePack nhỏ hơn 30–50% và nhanh hơn; đáng dùng cho đối tượng lớn hoặc lưu lượng cao.

Luôn bọc deserialize trong try/catch:

try { return JsonSerializer.Deserialize<T>(bytes, Options); }
catch (JsonException ex)
{
logger.LogWarning(ex, "Cache hỏng cho {Key}, bỏ qua", key);
await cache.RemoveAsync(key, ct);
return default; // coi nhu cache miss
}

Một entry hỏng không được làm sập request — coi nó là miss và đi nguồn.

14.4.4 — Thiết kế khoá​

public static class CacheKeys
{
private const string Version = "v2"; // tang khi doi hinh dang DTO

public static string Customer(int tenantId, int customerId)
=> $"{Version}:t{tenantId}:customer:{customerId}";

public static string CustomerList(int tenantId, int page, int size, string? q)
=> $"{Version}:t{tenantId}:customers:p{page}:s{size}:q{q ?? "-"}";

public static string UserPermissions(int userId)
=> $"{Version}:user:{userId}:permissions";
}

Bốn nguyên tắc:

  1. Có tenant id — nếu không là rò rỉ dữ liệu (bài 14.2).
  2. Có phiên bản — đổi DTO thì tăng, cache cũ tự bị bỏ qua.
  3. Phân cấp bằng dấu hai chấm — quy ước Redis, và công cụ quản trị hiển thị theo cây.
  4. Sinh từ hằng số, không nối chuỗi rải rác — gõ sai một chỗ là một khoá khác và tỷ lệ trúng 0.

Nguyên tắc 2 giải quyết vấn đề triển khai rất gọn: bạn không cần xoá cache cũ khi đổi hình dạng DTO — chúng tự hết hạn.

14.4.5 — Redis chết thì sao​

// SAI — Redis chet la ung dung chet
var cached = await _cache.GetAsync<CustomerDto>(key, ct);
// ĐÚNG — cache là tối ưu, không phải phụ thuộc bắt buộc
public async Task<CustomerDto?> GetAsync(int id, CancellationToken ct)
{
var key = CacheKeys.Customer(_tenant.Id, id);

try
{
var cached = await _cache.GetAsync<CustomerDto>(key, ct);
if (cached is not null) return cached;
}
catch (RedisConnectionException ex)
{
_logger.LogWarning(ex, "Redis không khả dụng, đọc thẳng database");
}

var customer = await LoadFromDatabaseAsync(id, ct);

if (customer is not null)
{
try { await _cache.SetAsync(key, customer, TimeSpan.FromMinutes(5), ct); }
catch (RedisConnectionException) { /* bỏ qua — không quan trọng */ }
}

return customer;
}

Nguyên tắc: cache là tối ưu, không phải phụ thuộc bắt buộc. Mất cache thì hệ thống chậm hơn, không dừng lại.

Ngoại lệ: nếu database không chịu nổi tải khi không có cache, thì Redis đã trở thành phụ thuộc bắt buộc — và bạn cần Redis Sentinel hoặc Cluster, cộng cơ chế hạ tải.

Health check cho Redis nên trả Degraded, không phải Unhealthy (bài 8.10):

builder.Services.AddHealthChecks()
.AddRedis(redisConnection, name: "redis",
failureStatus: HealthStatus.Degraded, tags: ["ready"]);

14.4.6 — Khi nào bỏ IDistributedCache​

IDistributedCache chỉ có năm thao tác, nên bạn mất gần như mọi thứ khiến Redis đáng dùng:

CầnIDistributedCacheRedis trực tiếp
Get/Set đơn giảnCóCó
Tăng bộ đếm nguyên tửKhôngINCR
Tập hợp, sắp xếp theo điểmKhôngSADD, ZADD
Xoá theo mẫu khoáKhôngSCAN + DEL
Pub/SubKhôngPUBLISH
Hết hạn theo trườngKhôngHash + TTL
Lua script nguyên tửKhôngEVAL
builder.Services.AddSingleton<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect(redisConnection));

// Bộ đếm nguyên tử — không làm được với IDistributedCache
var db = _redis.GetDatabase();
var count = await db.StringIncrementAsync($"views:{postId}");

// Rate limit phan tan
var requests = await db.StringIncrementAsync($"rate:{userId}");
if (requests == 1) await db.KeyExpireAsync($"rate:{userId}", TimeSpan.FromMinutes(1));
if (requests > 100) return TooManyRequests();

Ví dụ thứ hai giải quyết đúng giới hạn của rate limiter in-memory ở bài 9.7.

IConnectionMultiplexer phải là singleton — nó quản lý pool kết nối bên trong, và tạo nhiều instance là cách làm cạn kết nối tới Redis.

Mẫu thực dụng: dùng IDistributedCache cho cache thông thường (đổi provider được), và IConnectionMultiplexer cho những chỗ cần sức mạnh Redis.

14.4.7 — Lệnh không bao giờ chạy trên production​

KEYS *              -> quét TOÀN BỘ keyspace, CHẶN Redis (đơn luồng)
FLUSHALL -> xoá mọi thứ
DEBUG SLEEP -> chan Redis

Redis chạy đơn luồng cho lệnh, nên một KEYS * trên 10 triệu khoá chặn mọi lệnh khác trong nhiều giây. Đây là một trong những cách phổ biến nhất để vô tình gây sự cố.

// ĐÚNG — SCAN duyệt theo lô, không chặn
var server = _redis.GetServer(_redis.GetEndPoints()[0]);

await foreach (var key in server.KeysAsync(pattern: "crm:v2:t1:customer:*", pageSize: 250))
await _redis.GetDatabase().KeyDeleteAsync(key);

Nhưng SCAN vẫn chậm với keyspace lớn. Cách tốt hơn là duy trì một tập khoá để xoá theo nhóm:

// Khi cache
await db.SetAddAsync($"tag:customer:{customerId}", cacheKey);

// Khi cần xoá cả nhóm
var keys = await db.SetMembersAsync($"tag:customer:{customerId}");
await db.KeyDeleteAsync(keys.Select(k => (RedisKey)k.ToString()).ToArray());
await db.KeyDeleteAsync($"tag:customer:{customerId}");

Đây là nền tảng của xoá theo tag ở bài 14.5.

Ba cấu hình vận hành nên đặt:

maxmemory 2gb
maxmemory-policy allkeys-lru # dung cho cache thuan tuy

allkeys-lru khiến Redis tự dọn khoá ít dùng khi đầy, thay vì từ chối ghi. Nhưng nếu Redis cũng chứa dữ liệu không phải cache (session, hàng đợi), allkeys-lru sẽ xoá cả chúng — khi đó dùng volatile-lru và đặt TTL cho khoá cache.

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

Danh sách rà soát cache phân tán

  • •Chỉ cache vào Redis những thứ đắt để tạo ra, không phải chỉ hơi chậm.
  • •InstanceName được đặt nếu nhiều ứng dụng dùng chung Redis.
  • •AbortOnConnectFail = false để ứng dụng khởi động được khi Redis chưa lên.
  • •SyncTimeout đủ thấp để thất bại nhanh thay vì chờ lâu.
  • •Khoá cache có tenant id, phiên bản, và sinh từ hằng số.
  • •Deserialize được bọc try/catch và coi lỗi là cache miss.
  • •Redis không khả dụng không làm hỏng request; có fallback về nguồn.
  • •Health check Redis trả Degraded, không trả Unhealthy.
  • •IConnectionMultiplexer đăng ký Singleton.
  • •Không có KEYS hay FLUSHALL nào trong code.
  • •Xoá nhóm khoá dùng tập tag, không dùng SCAN theo mẫu.
  • •maxmemory và maxmemory-policy được cấu hình đúng theo nội dung Redis chứa.

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

Bài 1 — Khi Redis làm chậm ứng dụng​

Cache một truy vấn mất 2 ms vào Redis và đo tổng thời gian có cache so với không cache. Lặp lại với truy vấn mất 200 ms.

Tiêu chí hoàn thành: bạn tính được ngưỡng hoà vốn của một cache qua mạng, và giải thích được vì sao serialize chứ không phải mạng mới là chi phí lớn hơn ở nhiều trường hợp.

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

Gợi ý. Liệt kê từng bước mà một lần đọc cache Redis phải đi qua.

Lời giải — chi phí một lần đọc IDistributedCache với Redis:

1. Serialize khoá thành chuỗi                       ~0,00 ms
2. Gửi lệnh GET qua TCP tới Redis 0,3–1,0 ms (cùng vùng)
3. Redis xử lý ~0,05 ms
4. Nhận byte[] về 0,3–1,0 ms
5. Deserialize byte[] thành object 0,1–5,0 ms (tuỳ kích thước)
-----------
0,8–7,0 ms

Truy vấn 2 ms:

Không cache:     2,0 ms                      (chỉ truy vấn)
Có cache, trúng: 1,4 ms (Redis + deserialize)
Có cache, trượt: 1,4 + 2,0 + 1,2 = 4,6 ms (thử cache, truy vấn, ghi cache)

Với tỷ lệ trúng 90%: 0,9 × 1,4 + 0,1 × 4,6 = 1,72 ms

Nhanh hơn 0,28 ms — khoảng 14%, và bạn đã trả giá bằng một dependency mới, code xoá cache, và rủi ro dữ liệu cũ. Với tỷ lệ trúng 70%, cache chậm hơn không cache.

Truy vấn 200 ms:

Không cache:     200 ms
Có cache, trúng: 1,4 ms
Có cache, trượt: 202,6 ms

Với tỷ lệ trúng 90%: 0,9 × 1,4 + 0,1 × 202,6 = 21,5 ms

Nhanh hơn 9,3 lần. Đây mới là trường hợp cache đáng dùng.

Ngưỡng hoà vốn:

Thời gian trung bình có cache = p × C + (1 - p) × (C + Q + W)
Thời gian không cache = Q

trong đó p = tỷ lệ trúng, C = chi phí đọc cache,
Q = thời gian truy vấn, W = chi phí ghi cache

Hoà vốn khi: Q > C × (1 + (1-p)) / p (xấp xỉ, bỏ qua W)

Với C = 1,4 ms và các mức tỷ lệ trúng:

Tỷ lệ trúngTruy vấn phải chậm hơnGhi nhớ
50%~4,2 msCache qua mạng gần như vô ích
70%~2,6 ms
90%~1,7 ms
99%~1,4 msXấp xỉ bằng chi phí đọc cache

Nhưng đây là ngưỡng hoà vốn, không phải ngưỡng đáng làm. Một cache chỉ nhanh hơn 20% không xứng với độ phức tạp nó mang lại. Quy tắc thực dụng:

Truy vấn < 10 ms:     đừng cache qua Redis. Nếu cần, dùng IMemoryCache (~0,05 ms).
Truy vấn 10–50 ms: cache nếu tỷ lệ trúng trên 90%.
Truy vấn > 50 ms: cache gần như luôn đáng.

Vì sao serialize thường là chi phí lớn hơn mạng. Với Redis cùng vùng, mạng là 0,6–2 ms và khá ổn định. Serialize thì tỉ lệ với kích thước và độ phức tạp của đối tượng, và tăng rất nhanh:

Đối tượng            System.Text.Json (serialize + deserialize)
---------------------------------------------------------------
DTO 5 trường ~0,02 ms
Danh sách 100 DTO ~0,8 ms
Danh sách 1.000 DTO ~7 ms
Danh sách 10.000 DTO ~70 ms

Với một danh sách 10.000 phần tử, chi phí serialize át hẳn mạng — và át cả truy vấn database nếu truy vấn đó có index tốt. Bạn có thể gặp tình huống nghịch lý: đọc từ cache chậm hơn đọc từ database.

Ba cách giảm:

1. Cache ít dữ liệu hơn. Thường là câu trả lời đúng: đừng cache 10.000 phần tử, cache từng trang 20 phần tử, hoặc cache kết quả đã tổng hợp.

2. Dùng serializer nhanh hơn cho payload lớn:

services.AddHybridCache()
.AddSerializer<LeadDto, MessagePackSerializer<LeadDto>>();

MessagePack thường nhanh hơn System.Text.Json khoảng 2–4 lần và cho payload nhỏ hơn 30–50%. Đổi lại: không đọc được bằng mắt khi debug bằng redis-cli.

3. Dùng HybridCache để phần lớn lượt đọc không chạm Redis:

L1 (bộ nhớ, ~0,05 ms)  -> phục vụ khoảng 95% lượt đọc
L2 (Redis, ~1,4 ms) -> chỉ khi L1 trượt

Đây là lý do HybridCache thường nhanh hơn IDistributedCache thuần một bậc, dù dùng cùng một Redis phía dưới (bài 14.5).

Đo trên chính hệ thống của bạn, đừng tin bảng trên:

var sw = Stopwatch.StartNew();
var bytes = await _cache.GetAsync(khoa, ct);
var msMang = sw.Elapsed.TotalMilliseconds;

sw.Restart();
var giaTri = JsonSerializer.Deserialize<LeadDto>(bytes!);
var msSerialize = sw.Elapsed.TotalMilliseconds;

_logger.LogInformation("Cache {Khoa}: mạng {Mang:F2} ms, deserialize {Ser:F2} ms, {Bytes} byte",
khoa, msMang, msSerialize, bytes?.Length);

Tách hai con số ra là điều quan trọng: nếu mạng chậm thì đó là vấn đề hạ tầng, nếu serialize chậm thì đó là vấn đề thiết kế cache — và hai vấn đề cần hai cách sửa hoàn toàn khác nhau.


Bài 2 — KEYS chặn cả Redis​

Nạp 1 triệu khoá vào Redis dev, chạy KEYS * trong khi một tiến trình khác đọc liên tục, và đo độ trễ của tiến trình đó. Lặp lại với SCAN.

Tiêu chí hoàn thành: bạn giải thích được vì sao Redis đơn luồng biến KEYS thành sự cố toàn hệ thống, và biết những lệnh nào khác có cùng tính chất.

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

Gợi ý. Redis xử lý lệnh bằng bao nhiêu luồng?

Lời giải:

# Nạp 1 triệu khoá
redis-cli --pipe < <(for i in $(seq 1 1000000); do echo "SET lead:$i giatri$i"; done)

# Tiến trình đo độ trễ, chạy liên tục
redis-cli --latency

# Ở một cửa sổ khác
redis-cli KEYS "lead:*"
Bình thường:        min: 0, max: 1,   avg: 0.12 ms
Trong khi KEYS *: min: 0, max: 1240, avg: 412.30 ms
redis-cli --scan --pattern "lead:*" > /dev/null
Trong khi SCAN:     min: 0, max: 3,   avg: 0.18 ms

KEYS làm độ trễ tăng hơn 3.000 lần; SCAN gần như không ảnh hưởng.

Vì sao Redis đơn luồng biến KEYS thành sự cố toàn hệ thống. Redis xử lý lệnh trên một luồng duy nhất, tuần tự, và không có cơ chế ngắt giữa chừng:

Hàng đợi lệnh:  [GET a] [SET b] [KEYS *] [GET c] [GET d] [SET e] ...
^^^^^^^
chạy 1,2 giây — MỌI lệnh phía sau đều chờ

KEYS duyệt toàn bộ không gian khoá và so khớp mẫu cho từng khoá. Với 1 triệu khoá, đó là hơn một giây làm việc liên tục, và trong suốt thời gian đó Redis không xử lý bất cứ lệnh nào khác.

Hệ quả dây chuyền trên một ứng dụng thật:

t=0,0   ai đó chạy KEYS * để debug
t=0,0 mọi lệnh Redis khác bắt đầu xếp hàng
t=0,5 client bắt đầu timeout (mặc định StackExchange.Redis là 5 giây,
nhưng ứng dụng thường đặt thấp hơn)
t=1,2 KEYS xong, hàng đợi bắt đầu được xả
t=1,3 hàng nghìn request đã timeout và đang retry
t=1,5 retry dội vào cùng lúc -> Redis lại nghẽn

Điều khiến nó nguy hiểm: Redis không có cơ chế bảo vệ nào. Không có timeout cho lệnh, không có hàng đợi ưu tiên, không có cách huỷ một lệnh đang chạy. Bạn chỉ có thể chờ nó xong.

Và đây thường là sự cố tự gây ra: KEYS gần như luôn được gõ bởi một lập trình viên đang debug production, không phải bởi ứng dụng.

SCAN — duyệt theo từng lô, có con trỏ:

SCAN 0 MATCH "lead:*" COUNT 100
# -> 1) "17" <- con trỏ cho lần gọi tiếp theo
# 2) 1) "lead:42"
# 2) "lead:87"

SCAN 17 MATCH "lead:*" COUNT 100
# ... lặp cho tới khi con trỏ trả về "0"

Mỗi lần gọi làm một lượng việc nhỏ và cố định, nên các lệnh khác chen vào được giữa các lô.

var server = _redis.GetServer(_redis.GetEndPoints().First());
await foreach (var khoa in server.KeysAsync(pattern: "lead:*", pageSize: 100))
{
await _redis.GetDatabase().KeyDeleteAsync(khoa);
}

StackExchange.Redis tự dùng SCAN cho KeysAsync khi server hỗ trợ — nhưng hãy đặt pageSize tường minh, vì mặc định có thể lớn hơn bạn muốn.

Đánh đổi của SCAN: nó chỉ bảo đảm yếu. Khoá tồn tại suốt quá trình duyệt chắc chắn được trả về ít nhất một lần, nhưng:

  • Một khoá có thể được trả về nhiều lần — code của bạn phải chịu được điều đó.
  • Khoá được thêm hoặc xoá trong lúc duyệt thì không xác định.

Với việc xoá cache thì cả hai đều vô hại. Với việc đếm chính xác thì không.

Những lệnh khác có cùng tính chất — chạy trong O(N) hoặc tệ hơn trên một luồng duy nhất:

LệnhChi phíDùng thay bằng
KEYS patternO(N) toàn bộ khoáSCAN
FLUSHALL / FLUSHDBO(N)FLUSHALL ASYNC
SMEMBERS trên set lớnO(N)SSCAN
HGETALL trên hash lớnO(N)HSCAN
LRANGE list 0 -1O(N)LRANGE theo trang
ZRANGE toàn bộO(N)ZRANGE theo trang
SORT trên tập lớnO(N log N)Sắp xếp ở phía ứng dụng
DEL nhiều khoá lớnO(N) theo phần tửUNLINK (giải phóng ở luồng nền)
Script Lua dàiTuỳ scriptChia nhỏ script

Ba dòng giữa đáng chú ý vì chúng không trông nguy hiểm. HGETALL trên một hash 50 phần tử là bình thường; trên một hash 500.000 phần tử, nó có cùng tác hại như KEYS. Vấn đề không nằm ở tên lệnh mà ở kích thước cấu trúc dữ liệu — và kích thước đó lớn dần theo thời gian mà không ai để ý.

Ba cách phòng:

1. Đổi tên hoặc cấm lệnh trên production:

# redis.conf
rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG ""

Lệnh bị đổi tên thành chuỗi rỗng là bị vô hiệu hoá. Đây là biện pháp hiệu quả nhất vì nó chặn cả con người lẫn code.

2. Đặt ngưỡng cảnh báo lệnh chậm:

redis-cli CONFIG SET slowlog-log-slower-than 10000   # ghi lệnh chậm hơn 10 ms
redis-cli SLOWLOG GET 10
1) 1) (integer) 14
2) (integer) 1758787200
3) (integer) 1240518 <- micro giây: 1,24 giây
4) 1) "KEYS"
2) "lead:*"

SLOWLOG nên được đọc định kỳ, không chỉ khi có sự cố — nó cho bạn thấy lệnh nào đang chậm dần theo kích thước dữ liệu.

3. Thiết kế khoá để không bao giờ cần duyệt. Đây là cách bền vững nhất: nếu bạn đang cần KEYS để xoá theo mẫu, thiết kế khoá đang thiếu một chiều.

// Thay vì duyệt tìm "lead:*:tenant=acme" để xoá
// Giữ sẵn một set chứa các khoá thuộc tenant đó
await db.SetAddAsync($"tag:tenant:acme", $"lead:{id}");

// Khi cần xoá: đọc set rồi xoá đúng những khoá đó
var khoa = await db.SetMembersAsync("tag:tenant:acme");
await db.KeyDeleteAsync(khoa.Select(k => (RedisKey)k.ToString()).ToArray());
await db.KeyDeleteAsync("tag:tenant:acme");

Hoặc dùng xoá theo tag có sẵn của HybridCache (bài 14.5), vốn đã cài đúng mẫu này.


Bài 3 — Redis chết thì ứng dụng phải làm gì​

Dừng Redis khi ứng dụng đang chạy. Xác nhận request vẫn thành công (chậm hơn) và health check trả Degraded chứ pod không bị rút khỏi load balancer.

Tiêu chí hoàn thành: bạn phân biệt được Degraded và Unhealthy, và biết endpoint nào phải trả cái nào.

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

Gợi ý. Cache là tối ưu hoá. Hỏi: không có nó thì ứng dụng còn làm được việc không?

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

public async Task<LeadDto?> LayLeadAsync(int id, CancellationToken ct)
{
var bytes = await _cache.GetAsync($"lead:{id}", ct); // ném khi Redis chết
if (bytes is not null) return JsonSerializer.Deserialize<LeadDto>(bytes);

var lead = await TruyVanAsync(id, ct);
await _cache.SetAsync($"lead:{id}", JsonSerializer.SerializeToUtf8Bytes(lead), ct);
return lead;
}
docker stop redis
curl http://localhost:5000/leads/123
StackExchange.Redis.RedisConnectionException: No connection is active/available
to service this operation: GET lead:123

HTTP 500

Redis chết kéo theo toàn bộ ứng dụng chết, dù database vẫn hoạt động hoàn hảo. Một thành phần được thêm vào để tăng tốc lại trở thành điểm chết duy nhất.

Bản sửa — cache lỗi thì bỏ qua cache:

public async Task<LeadDto?> LayLeadAsync(int id, CancellationToken ct)
{
var khoa = $"lead:{id}";

try
{
var bytes = await _cache.GetAsync(khoa, ct);
if (bytes is not null) return JsonSerializer.Deserialize<LeadDto>(bytes);
}
catch (RedisConnectionException ex)
{
_logger.LogWarning(ex, "Không đọc được cache cho {Khoa}, đọc thẳng từ database", khoa);
_cacheLoi.Add(1); // metric, để cảnh báo
}

var lead = await TruyVanAsync(id, ct);

try
{
await _cache.SetAsync(khoa, JsonSerializer.SerializeToUtf8Bytes(lead), ct);
}
catch (RedisConnectionException ex)
{
_logger.LogWarning(ex, "Không ghi được cache cho {Khoa}", khoa);
}

return lead;
}
docker stop redis
curl http://localhost:5000/leads/123 -> HTTP 200, 42 ms thay vì 2 ms

Đừng viết try/catch này ở mọi chỗ gọi — bọc một lần trong lớp cache:

public class CacheChiuLoi : ICacheService
{
public async Task<T?> LayAsync<T>(string khoa, CancellationToken ct)
{
try { return await _trong.LayAsync<T>(khoa, ct); }
catch (RedisConnectionException ex) { GhiNhan(ex, khoa); return default; }
catch (RedisTimeoutException ex) { GhiNhan(ex, khoa); return default; }
}
}

Chú ý bắt cả RedisTimeoutException: Redis còn sống nhưng quá tải thì ném timeout chứ không ném connection exception, và trường hợp đó phổ biến hơn Redis chết hẳn.

Đặt timeout ngắn — nếu không, request chờ Redis 5 giây rồi mới đi tới database, và bạn vừa biến một sự cố cache thành một sự cố độ trễ:

var config = ConfigurationOptions.Parse(connectionString);
config.ConnectTimeout = 1000; // 1 giây
config.SyncTimeout = 1000;
config.AbortOnConnectFail = false; // QUAN TRỌNG: vẫn khởi động được khi Redis chưa sẵn sàng

AbortOnConnectFail = false là dòng hay bị bỏ sót nhất. Với mặc định true, ứng dụng không khởi động được nếu Redis chưa lên — và trong một cụm Kubernetes, thứ tự khởi động không được bảo đảm.

Degraded và Unhealthy — khác biệt và hậu quả:

HealthyDegradedUnhealthy
NghĩaMọi thứ bình thườngHoạt động được, kém hơnKhông phục vụ được
Mã HTTP mặc định200200503
Kubernetes làm gìGiữ nguyênGiữ nguyênRút khỏi service, có thể restart
Dùng choCache chết, dịch vụ phụ chậmDatabase chết, thiếu cấu hình bắt buộc

Điểm mấu chốt: Degraded trả về 200, nên Kubernetes coi pod là sẵn sàng và tiếp tục gửi traffic. Đó chính là điều bạn muốn khi chỉ có cache chết.

Nếu bạn đánh dấu Redis là Unhealthy, chuyện sau đây xảy ra:

Redis chết
-> mọi pod trả Unhealthy
-> Kubernetes rút TOÀN BỘ pod khỏi service
-> không còn pod nào nhận traffic
-> ứng dụng ngừng hoạt động hoàn toàn

...dù database vẫn chạy và ứng dụng vẫn phục vụ được.

Một sự cố cache trở thành một sự cố toàn hệ thống, do chính cấu hình health check gây ra.

Cấu hình đúng:

builder.Services.AddHealthChecks()
// Database chết -> không phục vụ được -> Unhealthy
.AddDbContextCheck<CrmDbContext>(
name: "database",
failureStatus: HealthStatus.Unhealthy,
tags: ["ready"])

// Redis chết -> chậm hơn nhưng vẫn chạy -> Degraded
.AddRedis(
redisConnectionString,
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: ["ready"]);

Và tách hai endpoint — đây là phần dễ sai nhất:

// Liveness: tiến trình còn sống không? KHÔNG kiểm tra dependency nào.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false,
});

// Readiness: nhận traffic được không? Kiểm tra dependency bắt buộc.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
});
EndpointKiểm tra gìNếu thất bại
/health/liveKhông gì cả — chỉ cần tiến trình phản hồiKubernetes restart pod
/health/readyDependency bắt buộcKubernetes rút khỏi service, không restart

Sai lầm phổ biến là kiểm tra database trong liveness probe:

Database chậm 30 giây
-> liveness probe timeout
-> Kubernetes RESTART mọi pod
-> pod mới khởi động, cùng lúc mở kết nối mới tới database đang quá tải
-> database càng chậm
-> vòng lặp restart không dừng

Liveness chỉ nên trả lời "tiến trình này có bị treo không?". Mọi thứ liên quan tới dependency thuộc về readiness.

Kiểm chứng bằng test tích hợp:

[Fact]
public async Task Redis_chet_thi_ung_dung_van_phuc_vu_duoc()
{
await _redisContainer.StopAsync();

var res = await _client.GetAsync("/leads/123");
res.StatusCode.Should().Be(HttpStatusCode.OK);

var health = await _client.GetAsync("/health/ready");
health.StatusCode.Should().Be(HttpStatusCode.OK, "Degraded vẫn trả 200");

var noiDung = await health.Content.ReadAsStringAsync();
noiDung.Should().Contain("Degraded");
}

Test này cần một container Redis thật mà bạn dừng được — Testcontainers là công cụ phù hợp. Nó là một trong số ít test đáng viết cho kịch bản hỏng hóc, vì hậu quả của việc cấu hình sai rất lớn và không có cách nào phát hiện bằng đọc code.

Tự kiểm tra​

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

Vì sao cache một truy vấn nhanh vào Redis lại phản tác dụng?

Vì một lượt đi về Redis mất khoảng nửa tới một mili giây, cộng chi phí serialize và deserialize thường còn lớn hơn cả mạng. Cache truy vấn 2 mili giây vào Redis là làm chậm hệ thống; chỉ nên cache những thứ đắt để tạo ra.

InstanceName trong cấu hình Redis để làm gì?

Nó thêm tiền tố vào mọi khoá, cần thiết khi nhiều ứng dụng dùng chung một Redis. Không có nó, khoá của ứng dụng này va chạm với ứng dụng kia.

Làm sao xử lý việc đổi hình dạng DTO đã cache?

Đưa phiên bản vào khoá cache. Khi đổi DTO thì tăng phiên bản, cache cũ tự bị bỏ qua và hết hạn, nên bạn không cần xoá gì khi triển khai. Thêm trường thường vẫn deserialize được, nhưng xoá hoặc đổi kiểu thì sẽ ném exception.

Redis chết thì ứng dụng nên làm gì?

Vẫn phục vụ được, chỉ chậm hơn, vì cache là tối ưu chứ không phải phụ thuộc bắt buộc. Bọc lời gọi cache trong try catch và đi thẳng nguồn khi lỗi, và health check Redis nên trả Degraded chứ không phải Unhealthy để pod không bị rút khỏi load balancer.

IDistributedCache che mất những gì của Redis?

Bộ đếm nguyên tử, tập hợp và sorted set, xoá theo mẫu khoá, pub sub, và Lua script. Nó chỉ có năm thao tác cơ bản. Mẫu thực dụng là dùng IDistributedCache cho cache thông thường và IConnectionMultiplexer cho những chỗ cần sức mạnh Redis.

Vì sao KEYS là lệnh nguy hiểm trên production?

Redis chạy đơn luồng cho lệnh, nên KEYS quét toàn bộ keyspace sẽ chặn mọi lệnh khác trong nhiều giây với keyspace lớn. Dùng SCAN để duyệt theo lô, hoặc tốt hơn là duy trì một tập khoá theo tag để xoá theo nhóm.

Kết luận​

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

  1. Serialize thường đắt hơn mạng. Chỉ cache vào Redis thứ đắt để tạo ra.
  2. Redis chết không được làm hỏng request. Cache là tối ưu, không phải phụ thuộc.
  3. KEYS chặn cả Redis. Dùng tập tag để xoá theo nhóm.

Tham khảo​

Điều hướng​