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

14.11 — Mở rộng và đào sâu

Tóm tắt

Những chủ đề nối tiếp Module 14, kèm điều kiện cụ thể. Điều bị bỏ qua nhiều nhất: Redis không chỉ là một kho khoá-giá trị. Nó có sorted set, hash, stream, HyperLogLog — và mỗi cấu trúc giải quyết một bài toán mà nếu làm bằng GET/SET sẽ vừa chậm vừa có race condition. Ví dụ rõ nhất: bảng xếp hạng làm bằng sorted set là một lệnh; làm bằng cách đọc, sắp xếp trong ứng dụng rồi ghi lại là ba lượt đi về kèm race condition. Trong phần job, Channel<T> là công cụ bị đánh giá thấp: nó là hàng đợi trong tiến trình có backpressure, đủ cho nhiều nhu cầu mà không cần đến broker.

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

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

  • Dùng đúng cấu trúc dữ liệu Redis cho từng bài toán.
  • Dùng Channel<T> cho hàng đợi trong tiến trình.
  • Chọn giữa Hangfire và Quartz.NET.
  • Biết cache warming và write-behind giải quyết gì.

Nội dung bài học​

14.11.1 — Cấu trúc dữ liệu Redis​

Điều kiện kích hoạt: ngay khi bài toán không phải là "lưu một giá trị theo khoá".

var db = _redis.GetDatabase();

// SORTED SET — bảng xếp hạng, MỘT lệnh
await db.SortedSetAddAsync("xephang:doanhso", nhanVienId, doanhSo);

var top10 = await db.SortedSetRangeByRankWithScoresAsync(
"xephang:doanhso", 0, 9, Order.Descending);

// Thứ hạng của một người — cũng MỘT lệnh
var thuHang = await db.SortedSetRankAsync("xephang:doanhso", nhanVienId, Order.Descending);

Làm bằng GET/SET: đọc cả danh sách, sắp xếp trong ứng dụng, ghi lại — ba lượt đi về, và race condition nếu hai request cùng cập nhật.

// HASH — cập nhật MỘT trường mà không đọc-sửa-ghi cả object
await db.HashSetAsync($"lead:{id}", "status", "Won");
var status = await db.HashGetAsync($"lead:{id}", "status");

// SET — kiểm tra thành viên, phép giao hợp
await db.SetAddAsync($"quyen:{userId}", "leads.write");
var coQuyen = await db.SetContainsAsync($"quyen:{userId}", "leads.write");

// HYPERLOGLOG — đếm số lượng DUY NHẤT, chỉ tốn 12 KB cho hàng triệu phần tử
await db.HyperLogLogAddAsync("luotxem:2026-09-24", sessionId);
var soNguoiDuyNhat = await db.HyperLogLogLengthAsync("luotxem:2026-09-24");
Cấu trúcDùng choThay cho
Sorted setXếp hạng, hàng đợi ưu tiên, dữ liệu theo thời gianĐọc-sắp xếp-ghi
HashObject nhiều trường, cập nhật từng phầnĐọc-sửa-ghi cả object
SetKiểm tra thành viên, tập quyềnDanh sách trong chuỗi JSON
HyperLogLogĐếm số lượng duy nhất xấp xỉSet khổng lồ

HyperLogLog đáng chú ý về mặt bộ nhớ: nó đếm xấp xỉ với sai số khoảng 0,8%, nhưng chỉ tốn 12 KB dù có hàng chục triệu phần tử. Với thống kê lượt truy cập, sai số đó hoàn toàn chấp nhận được, còn tiết kiệm bộ nhớ thì rất lớn.

14.11.2 — Channel: hàng đợi trong tiến trình​

Điều kiện kích hoạt: cần hàng đợi nội bộ, chưa cần broker.

// Dang ky
var channel = Channel.CreateBounded<EmailMessage>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait // BACKPRESSURE
});
builder.Services.AddSingleton(channel);

// Producer — trong controller hoac handler
await _channel.Writer.WriteAsync(message, ct);

// Consumer — BackgroundService
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var message in _channel.Reader.ReadAllAsync(ct))
{
try
{
await _emailSender.SendAsync(message, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Gửi email thất bại {To}", message.To);
}
}
}

CreateBounded với FullMode.Wait cho backpressure: khi hàng đợi đầy, producer chờ thay vì đẩy vô hạn vào bộ nhớ. Đây là khác biệt quan trọng so với CreateUnbounded, vốn có thể làm cạn bộ nhớ dưới tải cao.

Channel<T>Message broker
Phạm viTrong một tiến trìnhXuyên tiến trình
Bền qua restartKhông — mất hếtCó
Độ trễRất thấpCao hơn
Hạ tầngKhông cần gìCần vận hành broker

Hàng thứ hai là giới hạn quyết định: restart là mất message. Dùng Channel cho việc được phép mất — gửi email thông báo, ghi log, làm nóng cache. Việc không được mất thì dùng outbox và broker (bài 17.4).

try/catch trong vòng lặp consumer là bắt buộc: một exception thoát ra sẽ dừng ReadAllAsync và consumer chết im lặng.

14.11.3 — Quartz.NET và Hangfire​

Điều kiện kích hoạt: cần lịch chạy phức tạp hơn cron đơn giản.

builder.Services.AddQuartz(q =>
{
q.ScheduleJob<MonthlyReportJob>(trigger => trigger
.WithCronSchedule("0 0 8 1 * ?", // 8h sáng ngày 1 mỗi tháng
x => x.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Asia/Ho_Chi_Minh")))
.WithDescription("Báo cáo tháng"));
});
HangfireQuartz.NET
DashboardCó sẵnKhông (có gói riêng)
Lịch phức tạpCron cơ bảnRất mạnh — calendar, lịch loại trừ
Fire-and-forgetRất tiệnCồng kềnh hơn
RetryCó sẵnCấu hình thêm
ClusteringQua databaseCó sẵn
Độ dốc họcThấpCao hơn

Khuyến nghị: Hangfire cho hầu hết nhu cầu — dashboard và fire-and-forget là hai thứ dùng hàng ngày. Quartz.NET khi cần lịch thật sự phức tạp: "ngày làm việc cuối cùng của tháng, trừ ngày lễ".

14.11.4 — Cache warming​

Điều kiện kích hoạt: truy vấn nặng mà lần đầu sau khi hết hạn quá chậm.

public sealed class CacheWarmingJob(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(4)); // TTL la 5 phut

// Lam nong NGAY luc khoi dong
await LamNongAsync(ct);

while (await timer.WaitForNextTickAsync(ct))
{
try { await LamNongAsync(ct); }
catch (Exception ex) { _logger.LogError(ex, "Làm nóng cache thất bại"); }
}
}
}

Chu kỳ làm nóng ngắn hơn TTL (4 phút với TTL 5 phút) nên cache không bao giờ thật sự hết hạn — người dùng không bao giờ gặp cache miss cho dữ liệu quan trọng.

Nó cũng loại bỏ cache stampede cho những khoá được làm nóng, vì không có thời điểm nào chúng trống (bài 19.6).

Làm nóng ngay lúc khởi động cũng quan trọng: instance mới không nên phục vụ request đầu tiên với cache trống.

Chỉ áp dụng cho số ít khoá quan trọng — làm nóng mọi thứ là tự tạo tải nền vô ích.

14.11.5 — Write-behind cache​

Điều kiện kích hoạt: ghi rất nhiều, và trễ vài giây chấp nhận được.

// Ghi vào cache NGAY, đẩy xuống database sau
public async Task IncrementViewCountAsync(Guid leadId, CancellationToken ct)
{
await _redis.GetDatabase().HashIncrementAsync("lead:views", leadId.ToString());
// KHÔNG chạm database
}

// Job đẩy xuống database mỗi phút
public async Task FlushToDatabaseAsync(CancellationToken ct)
{
var db = _redis.GetDatabase();
var entries = await db.HashGetAllAsync("lead:views");

foreach (var entry in entries)
{
await _db.Leads
.Where(l => l.Id == Guid.Parse(entry.Name!))
.ExecuteUpdateAsync(s => s.SetProperty(
l => l.LuotXem, l => l.LuotXem + (int)entry.Value), ct);
}

await db.KeyDeleteAsync("lead:views");
}

Với đếm lượt xem, nó giảm số lần ghi database từ hàng nghìn mỗi phút xuống một lần mỗi phút.

Rủi ro rõ ràng: Redis chết thì mất dữ liệu chưa đẩy xuống. Chỉ dùng cho dữ liệu chấp nhận mất được: đếm lượt xem, thống kê không quan trọng, mốc thời gian truy cập gần nhất.

Không bao giờ dùng cho dữ liệu nghiệp vụ như số dư, tồn kho hay trạng thái đơn hàng.

14.11.6 — Thứ tự nên học​

Ưu tiênChủ đềVì sao
CaoCấu trúc dữ liệu RedisGiải đúng bài toán, tránh race condition
CaoChannel<T>Hàng đợi nội bộ không cần hạ tầng
Trung bìnhCache warmingLoại bỏ cache miss cho khoá quan trọng
Trung bìnhQuartz.NETKhi lịch chạy phức tạp
ThấpWrite-behindChỉ cho dữ liệu chấp nhận mất

Sau Module 14, bước tiếp theo là Module 15 — Docker và Deployment.

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

Danh sách rà soát cache và job nâng cao

  • •Bảng xếp hạng dùng sorted set, không đọc-sắp xếp-ghi.
  • •Object nhiều trường dùng hash, không đọc-sửa-ghi cả object.
  • •Channel dùng CreateBounded để có backpressure.
  • •Vòng lặp consumer của Channel có try catch bên trong.
  • •Chỉ dùng Channel cho việc được phép mất khi restart.
  • •Cache warming chỉ áp dụng cho số ít khoá quan trọng.
  • •Chu kỳ làm nóng ngắn hơn TTL.
  • •Write-behind chỉ dùng cho dữ liệu chấp nhận mất.

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

Bài 1 — Bảng xếp hạng bằng sorted set​

Cài bảng xếp hạng bằng sorted set và so sánh số lệnh với cách đọc-sắp xếp-ghi.

Tiêu chí hoàn thành: bạn nêu được vì sao cách đọc-sắp xếp-ghi sai về tính đúng đắn, không chỉ chậm hơn.

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

Gợi ý. Hai người cùng ghi điểm trong cùng một khoảnh khắc. Chuyện gì xảy ra với cách đọc-sắp xếp-ghi?

Lời giải — cách đọc-sắp-xếp-ghi:

public async Task CapNhatDiemAsync(string nguoiDung, int diem, CancellationToken ct)
{
var json = await _cache.GetStringAsync("bang-xep-hang", ct);
var bang = JsonSerializer.Deserialize<Dictionary<string, int>>(json ?? "{}")!;

bang[nguoiDung] = diem;

var sapXep = bang.OrderByDescending(kv => kv.Value)
.Take(100)
.ToDictionary(kv => kv.Key, kv => kv.Value);

await _cache.SetStringAsync("bang-xep-hang", JsonSerializer.Serialize(sapXep), ct);
}
Mỗi lần cập nhật:  1 GET + deserialize + sắp xếp N phần tử + serialize + 1 SET
Với 10.000 người: ~450 KB truyền đi, ~900 KB truyền về, mỗi lần cập nhật

Cách sorted set:

public async Task CapNhatDiemAsync(string nguoiDung, int diem, CancellationToken ct)
=> await _db.SortedSetAddAsync("bang-xep-hang", nguoiDung, diem);

public async Task<RedisValue[]> TopAsync(int n)
=> await _db.SortedSetRangeByRankAsync("bang-xep-hang", 0, n - 1, Order.Descending);

public async Task<long?> ThuHangAsync(string nguoiDung)
=> await _db.SortedSetRankAsync("bang-xep-hang", nguoiDung, Order.Descending);

public async Task<double> TangDiemAsync(string nguoiDung, double them)
=> await _db.SortedSetIncrementAsync("bang-xep-hang", nguoiDung, them);
Thao tácĐọc-sắp xếp-ghiSorted set
Cập nhật điểm2 lệnh + sắp xếp N1 lệnh, O(log N)
Lấy top 1001 lệnh + deserialize toàn bộ1 lệnh, O(log N + 100)
Tìm thứ hạng một người1 lệnh + duyệt toàn bộ1 lệnh, O(log N)
Tăng điểm2 lệnh, có race condition1 lệnh, nguyên tử
Dữ liệu truyền mỗi lần cập nhật~1,3 MBvài chục byte

Vì sao cách đọc-sắp xếp-ghi sai về tính đúng đắn — đây là phần chính, và nó quan trọng hơn khác biệt về tốc độ.

Đó lại là mẫu kiểm-tra-rồi-hành-động, lần này dưới dạng đọc-sửa-ghi:

t=0,000  Request A: GET -> { "an": 100, "binh": 200 }
t=0,001 Request B: GET -> { "an": 100, "binh": 200 } <- cùng ảnh chụp
t=0,010 Request A: SET -> { "an": 150, "binh": 200 }
t=0,011 Request B: SET -> { "an": 100, "binh": 250 } <- GHI ĐÈ điểm của An

Điểm mới của An biến mất. Đây chính là lost update ở bài 13.13, nhưng trên Redis — nơi không có RowVersion để phát hiện.

Và nó tệ hơn ở Redis vì hai lý do:

  1. Không có cơ chế phát hiện nào. SET luôn thành công, luôn trả OK. Không có exception, không có số dòng bị ảnh hưởng để kiểm tra.
  2. Toàn bộ cấu trúc bị ghi đè, không chỉ một trường. Một SET mất có thể xoá hàng chục cập nhật của người khác.

Với bảng xếp hạng cập nhật vài chục lần mỗi giây, lỗi này xảy ra liên tục — và không ai phát hiện, vì không có cách nào biết điểm "đúng ra phải là bao nhiêu".

Sorted set đúng vì mỗi lệnh là nguyên tử. ZADD sửa một phần tử trong cấu trúc, không ghi đè cả cấu trúc. Redis đơn luồng nên hai ZADD bắt buộc tuần tự, và cả hai đều có hiệu lực:

t=0,000  A: ZADD bang-xep-hang 150 "an"
t=0,001 B: ZADD bang-xep-hang 250 "binh"
-> { "an": 150, "binh": 250 } — cả hai được giữ

ZINCRBY còn mạnh hơn: nó cộng thay vì đặt, nên không cần biết giá trị cũ. Hai lần tăng đồng thời cho ra tổng đúng, không như hai lần SET.

Ba cấu trúc dữ liệu Redis khác thường bị bỏ qua:

1. HASH — cập nhật một trường mà không ghi đè cả object:

// Thay vì GET, sửa, SET cả object
await _db.HashSetAsync($"lead:{id}", "status", "Won");
await _db.HashIncrementAsync($"lead:{id}", "luot_xem", 1);
var trangThai = await _db.HashGetAsync($"lead:{id}", "status");

Cùng lợi ích như sorted set: nguyên tử theo từng trường, và chỉ truyền trường bạn cần.

2. SET — kiểm tra thành viên và phép toán tập hợp:

await _db.SetAddAsync($"lead:{id}:tags", "quan-trong");
var co = await _db.SetContainsAsync($"lead:{id}:tags", "quan-trong"); // O(1)

// Lead có CẢ hai nhãn — Redis tự giao hai tập
var giao = await _db.SetCombineAsync(SetOperation.Intersect,
["tag:quan-trong", "tag:b2b"]);

Phép giao chạy hoàn toàn trong Redis, không truyền hai tập về ứng dụng.

3. HyperLogLog — đếm số lượng phần tử phân biệt trong 12 KB cố định:

await _db.HyperLogLogAddAsync($"khach-truy-cap:{DateTime.UtcNow:yyyy-MM-dd}", userId);
var soKhach = await _db.HyperLogLogLengthAsync($"khach-truy-cap:{DateTime.UtcNow:yyyy-MM-dd}");

Sai số khoảng 0,81%, nhưng bộ nhớ là 12 KB bất kể đếm 100 hay 100 triệu phần tử phân biệt. Một SET chứa 100 triệu user id sẽ tốn vài GB.

Nguyên tắc rút ra, và đây là điều đáng mang theo:

Nếu bạn đang GET rồi sửa rồi SET trên Redis, gần như chắc chắn có một cấu trúc dữ liệu phù hợp hơn.

IDistributedCache chỉ phơi ra Get và Set chuỗi byte, nên nó che mất toàn bộ phần này của Redis. Muốn dùng, phải làm việc trực tiếp với IConnectionMultiplexer:

services.AddSingleton<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect(chuoiKetNoi));

Và đây là lý do chính đáng nhất để không dùng IDistributedCache cho mọi thứ: nó biến Redis thành một kho khoá-giá trị, trong khi giá trị lớn nhất của Redis nằm ở các cấu trúc dữ liệu nguyên tử mà nó cung cấp.


Bài 2 — Backpressure với Channel​

Tạo Channel bounded 10 phần tử, ghi 100 phần tử nhanh, và quan sát producer bị chặn.

Tiêu chí hoàn thành: bạn giải thích được vì sao channel unbounded nguy hiểm hơn nó trông, và chọn được FullMode phù hợp.

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

Gợi ý. Producer nhanh hơn consumer. Dữ liệu thừa đi đâu?

Lời giải:

var channel = Channel.CreateBounded<int>(new BoundedChannelOptions(10)
{
FullMode = BoundedChannelFullMode.Wait,
});

var consumer = Task.Run(async () =>
{
await foreach (var x in channel.Reader.ReadAllAsync())
{
await Task.Delay(100); // consumer chậm
Console.WriteLine($" đọc {x}");
}
});

var sw = Stopwatch.StartNew();
for (var i = 1; i <= 100; i++)
{
await channel.Writer.WriteAsync(i); // CHẶN khi đầy
Console.WriteLine($"ghi {i} tại {sw.ElapsedMilliseconds} ms");
}
channel.Writer.Complete();
await consumer;
ghi 1  tại    0 ms
ghi 2 tại 0 ms
...
ghi 10 tại 1 ms
ghi 11 tại 103 ms <- bắt đầu bị chặn, đi đúng nhịp consumer
ghi 12 tại 205 ms
...
ghi 100 tại 9.012 ms

Mười phần tử đầu vào ngay; từ đó producer đi đúng nhịp consumer. Đây là backpressure: người tiêu thụ chậm tự động làm chậm người sản xuất, không cần phối hợp gì.

Vì sao channel unbounded nguy hiểm hơn nó trông:

var channel = Channel.CreateUnbounded<int>();      // "không giới hạn"
ghi 1   tại 0 ms
ghi 2 tại 0 ms
...
ghi 100 tại 2 ms <- producer xong ngay, consumer mới đọc được 1 phần tử

Producer không bao giờ chờ, nên nếu nó nhanh hơn consumer, hàng đợi lớn mãi.

Điều khiến nó nguy hiểm không phải là "tốn bộ nhớ" — mà là cách nó hỏng:

1. Không có tín hiệu cảnh báo nào. Hệ thống chạy bình thường, throughput trông tốt, độ trễ đầu vào thấp. Chỉ có bộ nhớ tăng đều — và trên một biểu đồ nhiều đường, nó trông như bình thường trong nhiều giờ.

2. Nó hỏng đột ngột, không suy giảm dần. Bộ nhớ tăng tuyến tính cho tới khi chạm giới hạn, rồi OutOfMemoryException hoặc pod bị giết ngay lập tức. Không có giai đoạn "chậm dần" để ai đó kịp nhận ra.

3. Toàn bộ hàng đợi mất khi tiến trình chết. Phần tử trong channel chỉ nằm trong bộ nhớ. Pod bị giết vì OOM sẽ mang theo 200.000 mục chưa xử lý — và không có gì để khôi phục chúng.

4. Nó che giấu vấn đề thật. Hàng đợi dài nghĩa là consumer không theo kịp — một vấn đề về năng lực cần được giải quyết. Channel unbounded biến nó thành một vấn đề về bộ nhớ, và trì hoãn việc phát hiện tới lúc quá muộn.

Bounded:    producer chậm lại -> độ trễ tăng -> ĐO ĐƯỢC, cảnh báo được
Unbounded: producer chạy full tốc -> bộ nhớ tăng -> chết đột ngột

Bốn FullMode và khi nào dùng:

FullModeHành vi khi đầyDùng cho
WaitProducer chờMặc định — không được mất dữ liệu
DropOldestVứt phần tử cũ nhấtDữ liệu thời gian thực: giá, vị trí, trạng thái
DropNewestVứt phần tử vừa ghiHiếm dùng
DropWriteVứt phần tử đang ghi, không chặnLog, metric, telemetry
// Dữ liệu thời gian thực: giá mới nhất quan trọng hơn giá cũ
var giaChannel = Channel.CreateBounded<CapNhatGia>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest,
});

// Telemetry: mất vài mục thì chấp nhận được, nhưng KHÔNG được làm chậm nghiệp vụ
var logChannel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.DropWrite,
});

Khác biệt quan trọng giữa DropWrite và Wait: với DropWrite, TryWrite trả false và không chặn. Điều đó là đúng cho log — một hệ thống không nên chậm lại vì không ghi kịp log.

Hai tối ưu nên bật khi biết rõ mô hình dùng:

var channel = Channel.CreateBounded<CongViec>(new BoundedChannelOptions(1000)
{
SingleReader = true, // chỉ một consumer
SingleWriter = false, // nhiều producer
FullMode = BoundedChannelFullMode.Wait,
});

SingleReader = true cho phép channel bỏ một số cơ chế đồng bộ, và cải thiện throughput đáng kể. Nhưng nếu bạn khai sai — khai true mà thực tế có hai consumer — hành vi là không xác định, không phải là một exception rõ ràng. Hãy chắc chắn trước khi bật.

Đo độ sâu hàng đợi — đây là chỉ số quan trọng nhất của mọi hệ thống producer-consumer:

_meter.CreateObservableGauge("channel.depth", () => channel.Reader.Count);
Độ sâu ổn định ở mức thấp     -> consumer theo kịp, hệ thống khoẻ
Độ sâu tăng đều -> consumer không theo kịp, cần thêm năng lực
Độ sâu chạm trần liên tục -> producer đang bị chặn, độ trễ đầu vào đang tăng

Đường cong này là thứ cho bạn biết trước khi có sự cố — và nó chỉ tồn tại khi channel là bounded. Channel unbounded không có trần để chạm, nên không có tín hiệu nào cho tới lúc OOM.

Và một giới hạn quan trọng: Channel chỉ nằm trong bộ nhớ. Nó không bền vững qua khởi động lại và không chia sẻ được giữa các tiến trình:

ChannelHàng đợi bền vững (RabbitMQ, Hangfire)
Phạm viMột tiến trìnhCả cụm
Sống sót khi restartKhôngCó
Độ trễVài trăm nano giâyVài mili giây
Phù hợpĐường ống trong tiến trình, gom lô, điều phốiCông việc không được phép mất

Dùng Channel cho việc mất được — gom log theo lô, điều tiết lời gọi API, phân phối công việc giữa các luồng. Dùng hàng đợi bền vững cho việc không mất được: gửi email, xử lý thanh toán, đồng bộ dữ liệu.

Mẫu hay dùng: gom lô bằng Channel:

await foreach (var lo in channel.Reader.ReadAllAsync(ct).Buffer(100, TimeSpan.FromSeconds(5)))
{
await _db.BulkInsertAsync(lo, ct); // 100 mục một lần thay vì từng mục
}

Gom 100 mục hoặc chờ tối đa 5 giây — cân bằng giữa throughput và độ trễ, và giảm số lần chạm database đi hai bậc độ lớn.


Bài 3 — Cache warming​

Cài job làm nóng với chu kỳ ngắn hơn TTL và xác nhận không còn cache miss.

Tiêu chí hoàn thành: bạn tính được khoảng cách an toàn giữa chu kỳ và TTL, và nêu được khi nào cache warming không đáng làm.

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

Gợi ý. Job làm nóng mất bao lâu để chạy xong? Nếu nó chạy chậm hơn dự kiến thì sao?

Lời giải:

public class LamNongCacheJob : BackgroundService
{
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan ChuKy = TimeSpan.FromMinutes(20);

protected override async Task ExecuteAsync(CancellationToken ct)
{
await LamNongAsync(ct); // chạy ngay lúc khởi động

using var timer = new PeriodicTimer(ChuKy);
while (await timer.WaitForNextTickAsync(ct))
{
try { await LamNongAsync(ct); }
catch (Exception ex) { _logger.LogError(ex, "Làm nóng cache thất bại"); }
}
}

private async Task LamNongAsync(CancellationToken ct)
{
var sw = Stopwatch.StartNew();

foreach (var tenantId in await LayTenantHoatDongAsync(ct))
{
var duLieu = await TinhBaoCaoAsync(tenantId, ct);
await _cache.SetAsync($"bao-cao:{tenantId}", duLieu,
new HybridCacheEntryOptions { Expiration = Ttl }, ct: ct);
}

_logger.LogInformation("Làm nóng cache xong trong {Ms} ms", sw.ElapsedMilliseconds);
}
}
Không làm nóng:  p99 = 2.400 ms (mỗi 30 phút có vài request phải chờ tính lại)
Có làm nóng: p99 = 45 ms

Tính khoảng cách an toàn giữa chu kỳ và TTL:

TTL ≥ chu kỳ + thời gian chạy tối đa + biên an toàn

Ba thành phần, và mỗi cái có lý do:

Thành phầnVì sao cầnƯớc lượng
Chu kỳKhoảng cách giữa hai lần làm nóngBạn chọn
Thời gian chạy tối đaJob phải kịp xong trước khi mục cũ hết hạnĐo p99, không phải trung bình
Biên an toànJob thất bại một lần thì lần sau vẫn kịpBằng một chu kỳ
Ví dụ: job mất 2 phút (p99), chu kỳ 20 phút

TTL tối thiểu = 20 + 2 + 20 = 42 phút
Chọn TTL = 45 phút cho tròn.

Biên an toàn bằng một chu kỳ là chi tiết quan trọng nhất: nó cho phép một lần làm nóng thất bại mà cache vẫn không hết hạn. Không có nó, một lỗi mạng thoáng qua sẽ khiến mọi mục cache hết hạn cùng lúc — và bạn có một cơn stampede trên chính thứ mà cache warming được dựng lên để tránh.

Sai lầm phổ biến: chu kỳ đúng bằng TTL.

TTL = 30 phút, chu kỳ = 30 phút

t=0 làm nóng, mục hết hạn lúc t=30
t=30,0 mục HẾT HẠN
t=30,1 job bắt đầu chạy
t=30,1..32,1 TRONG 2 PHÚT NÀY, MỌI REQUEST ĐỀU MISS

Hai phút stampede mỗi 30 phút — tệ hơn không có cache warming, vì giờ tất cả các mục cùng hết hạn đồng thời.

Bốn chi tiết cài đặt:

1. Chạy ngay lúc khởi động, đừng chờ tick đầu tiên. Không có nó, pod vừa khởi động phục vụ request với cache rỗng trong suốt một chu kỳ.

2. Không để exception làm chết job. PeriodicTimer trong BackgroundService mà ném exception sẽ dừng hẳn vòng lặp, và cache không bao giờ được làm nóng nữa — trong im lặng. Bọc try/catch bên trong vòng lặp.

3. Chạy như một singleton trong cụm. Mười pod cùng làm nóng là mười lần tính toán cho cùng một kết quả — dùng khoá phân tán hoặc bầu leader như ở bài 14.7.

4. Đo thời gian chạy và cảnh báo khi nó tiến gần chu kỳ:

Cảnh báo khi thời gian làm nóng > 50% chu kỳ.

Thời gian chạy tăng dần theo lượng dữ liệu. Một job mất 2 phút hôm nay có thể mất 15 phút sau một năm — và lúc đó nó không còn kịp, nhưng không có gì báo cho bạn ngoài tỷ lệ trúng cache đang tụt.

Khi nào cache warming không đáng làm — bốn trường hợp:

1. Nhiều khoá, mỗi khoá ít lượt truy cập. Làm nóng 200.000 lead để rồi 95% trong số đó không ai xem là lãng phí thuần tuý — bạn trả toàn bộ chi phí tính toán mà chỉ thu về 5% lợi ích. Đây cũng chính là trường hợp cache vốn đã không phù hợp (bài 14.1).

2. Dữ liệu đổi thường xuyên hơn chu kỳ làm nóng. Làm nóng mỗi 20 phút một dữ liệu đổi mỗi phút nghĩa là bạn đang chủ động ghi dữ liệu cũ vào cache, và giữ nó ở đó.

3. Truy vấn gốc đã đủ nhanh. Với truy vấn 50 ms, làm nóng tiết kiệm 50 ms cho một số ít request — không xứng với một job nền phải vận hành và giám sát.

4. Chi phí tính toán quá cao so với lợi ích. Job làm nóng chạy mỗi 20 phút cho 500 tenant, mỗi tenant một truy vấn nặng, là 1.500 truy vấn nặng mỗi giờ — có thể nhiều hơn số lần người ta thật sự mở báo cáo.

Cách tốt hơn cho trường hợp 1 và 4: làm mới sớm theo nhu cầu thật.

var muc = await _cache.GetOrCreateAsync(khoa, factory, options, ct: ct);

// Còn dưới 20% thời gian sống -> làm mới ngầm, không chặn request hiện tại
if (muc.ConLai < muc.Ttl * 0.2)
_ = Task.Run(() => LamMoiAsync(khoa), CancellationToken.None);

return muc.GiaTri;

Cách này chỉ làm nóng những khoá thật sự được dùng, nên nó thích nghi tự động với mẫu truy cập thay vì phải đoán trước. Và request hiện tại nhận giá trị cũ ngay lập tức thay vì chờ.

HybridCache gọi tính chất này là làm mới nền và đang phát triển hỗ trợ sẵn; cho tới lúc đó, đoạn trên là cách cài thủ công.

Bảng chọn:

Tình huốngCách
Ít khoá, tính toán rất đắt, ai cũng dùngCache warming theo lịch
Nhiều khoá, phân bố truy cập lệchLàm mới sớm theo nhu cầu
Dữ liệu đổi liên tụcKhông làm nóng, TTL ngắn
Truy vấn dưới 50 msKhông cache warming; cân nhắc bỏ cả cache

Và một trường hợp mà cache warming gần như luôn đáng: ngay sau khi triển khai. Pod mới khởi động với cache rỗng, và nếu bạn thay toàn bộ pod cùng lúc, mọi request trong vài phút đầu đều miss:

public class LamNongKhiKhoiDong : IHostedService
{
public async Task StartAsync(CancellationToken ct)
{
// Làm nóng TRƯỚC khi health check báo sẵn sàng
await LamNongCacBaoCaoQuanTrongAsync(ct);
}
}

Chạy nó trước khi /health/ready trả 200, và Kubernetes sẽ không gửi traffic tới pod cho tới khi cache đã sẵn sàng — biến một cơn stampede sau mỗi lần deploy thành một khoảng chờ vài giây lúc khởi động.

Tự kiểm tra​

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

Vì sao bảng xếp hạng nên dùng sorted set?

Vì thêm điểm và lấy thứ hạng đều là một lệnh duy nhất. Làm bằng GET và SET thì phải đọc cả danh sách, sắp xếp trong ứng dụng và ghi lại, tức ba lượt đi về kèm race condition khi hai request cùng cập nhật.

HyperLogLog đánh đổi gì?

Nó đếm số lượng duy nhất một cách xấp xỉ với sai số khoảng 0,8 phần trăm, nhưng chỉ tốn 12 KB dù có hàng chục triệu phần tử. Với thống kê lượt truy cập, sai số đó chấp nhận được còn tiết kiệm bộ nhớ thì rất lớn.

Vì sao Channel nên dùng CreateBounded?

Để có backpressure: khi hàng đợi đầy, producer chờ thay vì đẩy vô hạn vào bộ nhớ. CreateUnbounded có thể làm cạn bộ nhớ dưới tải cao.

Giới hạn quyết định của Channel so với broker là gì?

Restart là mất toàn bộ message vì nó chỉ nằm trong bộ nhớ tiến trình. Dùng nó cho việc được phép mất như gửi email thông báo hay ghi log; việc không được mất thì dùng outbox và broker.

Vì sao chu kỳ cache warming phải ngắn hơn TTL?

Để cache không bao giờ thật sự hết hạn, nên người dùng không gặp cache miss cho dữ liệu quan trọng, và cũng không có thời điểm nào khoá trống để xảy ra cache stampede.

Write-behind chỉ dùng cho loại dữ liệu nào?

Dữ liệu chấp nhận mất được như đếm lượt xem, thống kê không quan trọng hay mốc thời gian truy cập gần nhất. Không bao giờ dùng cho số dư, tồn kho hay trạng thái đơn hàng, vì Redis chết là mất phần chưa đẩy xuống.

Kết luận​

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

  1. Redis không chỉ là kho khoá-giá trị — dùng đúng cấu trúc tránh được cả race condition.
  2. Channel<T> là hàng đợi không cần hạ tầng, nhưng mất hết khi restart.
  3. Write-behind chỉ cho dữ liệu chấp nhận mất.

Tham khảo​

Điều hướng​