14.12 - Ví dụ thực tế nhanh
Tóm tắt: Một sự cố mà nguyên nhân nằm ở một dòng cấu hình không ai đặt: Redis chạm giới hạn bộ nhớ, và vì
maxmemory-policymặc định lànoeviction, nó từ chối mọi lệnh ghi thay vì xoá bớt khoá cũ. Ứng dụng nhậnOOM command not allowedở mỗi lần ghi cache — và vì code không bọc lời gọi cache trongtry/catch, mọi request đều lỗi 500. Nghịch lý: cache là thứ để tăng tốc, nhưng cấu hình sai khiến nó thành thứ làm sập hệ thống. Bài này chỉ nguyên nhân gốc (khoá không TTL), cấu hình đúng, và nguyên tắc: cache lỗi không được làm hỏng request.
Mục tiêu bài học
Sau bài này bạn có thể:
- Cấu hình
maxmemory-policyđúng. - Tìm khoá không có TTL trong Redis.
- Viết code cache chịu được khi Redis chết.
- Giám sát Redis bằng ba chỉ số.
Nội dung bài học
14.12.1 — Sự cố
14:20 Cảnh báo: Redis memory usage 98%
14:22 API bắt đầu trả 500 hàng loạt
14:23 Log: "OOM command not allowed when used memory > 'maxmemory'"
14:25 Tắt cache trong code -> hệ thống hoạt động lại (chậm hơn)// Code gây lỗi — không bắt exception
public async Task<Lead?> GetAsync(LeadId id, CancellationToken ct)
{
var cached = await _cache.GetStringAsync($"lead:{id}", ct);
if (cached is not null) return JsonSerializer.Deserialize<Lead>(cached);
var lead = await _repository.GetByIdAsync(id, ct);
// Redis đầy -> ném exception -> CẢ REQUEST thất bại
await _cache.SetStringAsync($"lead:{id}", JsonSerializer.Serialize(lead), ct);
return lead;
}Điểm nghịch lý: dữ liệu đã được lấy thành công từ database. Chỉ có bước ghi cache thất bại — một thao tác hoàn toàn không bắt buộc — nhưng nó làm hỏng cả request.
14.12.2 — Nguyên nhân: khoá không TTL
redis-cli INFO memory
# used_memory_human:7.98G
# maxmemory_human:8.00G
# maxmemory_policy:noeviction <- MẶC ĐỊNH# Đếm khoá KHÔNG có TTL
redis-cli --scan --pattern '*' | while read key; do
ttl=$(redis-cli TTL "$key")
[ "$ttl" = "-1" ] && echo "$key"
done | wc -l
# 2.847.193 khoá không bao giờ hết hạn// Thu pham — quen dat thoi han
await _cache.SetStringAsync($"session:{userId}", data, ct); // KHÔNG TTL
await _cache.SetStringAsync($"temp:calc:{id}", result, ct); // KHÔNG TTLKhoá không TTL không bao giờ biến mất. Redis đầy dần suốt nhiều tháng cho tới khi chạm trần.
TTL trả về -1 nghĩa là khoá tồn tại nhưng không có hạn; -2 nghĩa là khoá không tồn tại.
14.12.3 — Sửa 1: đặt maxmemory-policy
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# Và ghi vào redis.conf để giữ sau khi khởi động lại| Chính sách | Hành vi khi đầy |
|---|---|
noeviction | Từ chối ghi — mặc định, gây sự cố này |
allkeys-lru | Xoá khoá ít dùng gần đây nhất — tốt cho cache thuần |
volatile-lru | Chỉ xoá khoá có TTL |
allkeys-lfu | Xoá khoá ít dùng nhất theo tần suất |
Chọn thế nào:
- Redis chỉ dùng làm cache →
allkeys-lru. Mất một khoá chỉ là cache miss. - Redis chứa cả dữ liệu không được mất (hàng đợi, session bắt buộc) →
volatile-lru, và đảm bảo dữ liệu quan trọng không có TTL nên không bị xoá.
Trường hợp thứ hai cần cẩn thận: nếu mọi khoá đều có TTL thì volatile-lru hành xử như allkeys-lru; nếu không khoá nào có TTL thì nó hành xử như noeviction — và bạn quay lại sự cố ban đầu.
Khuyến nghị mạnh: tách Redis làm cache và Redis chứa dữ liệu bền thành hai instance riêng. Chúng có yêu cầu hoàn toàn khác nhau.
14.12.4 — Sửa 2: luôn đặt TTL
// LUON co thoi han
await _cache.SetStringAsync($"lead:{id}", json, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
}, ct);Cách đảm bảo không ai quên: bọc lại và bắt buộc truyền TTL.
public interface IAppCache
{
Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken ct);
// ^^^^^^^^ bắt buộc, không có giá trị mặc định
}Không có tham số tuỳ chọn, không có giá trị mặc định — trình biên dịch buộc mọi lời gọi phải quyết định thời hạn.
Nếu dùng HybridCache, nó có Expiration bắt buộc trong DefaultEntryOptions nên vấn đề này ít xảy ra hơn (bài 14.5).
14.12.5 — Sửa 3: cache lỗi không được làm hỏng request
Đây là nguyên tắc quan trọng nhất trong bài:
public async Task<Lead?> GetAsync(LeadId id, CancellationToken ct)
{
// Đọc cache — thất bại thì coi như cache miss
try
{
var cached = await _cache.GetStringAsync($"lead:{id}", ct);
if (cached is not null) return JsonSerializer.Deserialize<Lead>(cached);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Đọc cache thất bại cho {Key}, bỏ qua", id);
}
var lead = await _repository.GetByIdAsync(id, ct);
// Ghi cache — thất bại thì BỎ QUA
try
{
await _cache.SetStringAsync($"lead:{id}", JsonSerializer.Serialize(lead),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
}, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Ghi cache thất bại cho {Key}, bỏ qua", id);
}
return lead;
}Nguyên tắc: Redis chết thì hệ thống chậm, không phải hệ thống sập.
Đặt thêm timeout ngắn để Redis chậm cũng không kéo theo request chậm:
options.ConfigurationOptions = new ConfigurationOptions
{
ConnectTimeout = 1000,
SyncTimeout = 1000,
AbortOnConnectFail = false // tự kết nối lại, không ném exception lúc khởi động
};AbortOnConnectFail = false quan trọng: mặc định, nếu Redis chưa sẵn sàng lúc ứng dụng khởi động, thư viện ném exception và ứng dụng không khởi động được — điều đó biến một phụ thuộc tuỳ chọn thành phụ thuộc bắt buộc.
Ngoại lệ hợp lý: nếu Redis lưu session bắt buộc hoặc trạng thái rate limit, mất nó là lỗi thật và nên để exception thoát ra. Phân biệt rõ hai loại dữ liệu này.
14.12.6 — Ba lệnh kiểm tra
# 1. Bộ nhớ và chính sách
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|maxmemory_policy"
# 2. Khoá chiếm nhiều bộ nhớ nhất
redis-cli --bigkeys
# 3. Ty le cache hit
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"Lệnh thứ ba cho chỉ số quan trọng nhất về hiệu quả của cache:
hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)| Tỷ lệ hit | Nghĩa là |
|---|---|
| Trên 90% | Cache đang hiệu quả |
| 50–90% | Xem lại TTL hoặc chiến lược khoá |
| Dưới 50% | Cache có thể đang gây hại nhiều hơn giúp |
Hàng cuối đáng chú ý: tỷ lệ hit thấp nghĩa là phần lớn lần đọc cache đều miss, nên bạn trả chi phí của một lượt đi về Redis mà vẫn phải truy vấn database. Khi đó nên xem lại xem dữ liệu đó có đáng cache không (bài 19.5).
--bigkeys cảnh báo: nó quét toàn bộ keyspace nên tốn tài nguyên. Trên production, chạy nó vào giờ thấp điểm.
14.12.7 — Rà lại code của bạn
Danh sách rà soát Redis
- maxmemory-policy được đặt tường minh, không để mặc định noeviction.
- Mọi lần ghi cache đều có TTL.
- Interface cache bắt buộc truyền TTL, không có giá trị mặc định.
- Lỗi đọc và ghi cache được bắt và bỏ qua.
- ConnectTimeout và SyncTimeout được đặt ngắn.
- AbortOnConnectFail đặt false để ứng dụng khởi động được khi Redis chưa sẵn sàng.
- Redis làm cache và Redis chứa dữ liệu bền là hai instance riêng.
- Có giám sát bộ nhớ Redis và tỷ lệ cache hit.
Bài tập áp dụng
Bài 1 — Tái hiện Redis đầy bộ nhớ
Đặt maxmemory rất thấp với noeviction, ghi cho tới khi đầy, và quan sát lỗi trong ứng dụng.
Tiêu chí hoàn thành: bạn giải thích được vì sao noeviction là mặc định, và chọn được chính sách đúng cho Redis dùng làm cache.
Gợi ý và lời giải — Bài 1
Gợi ý. Redis không chỉ được dùng làm cache. Nếu nó đang lưu dữ liệu duy nhất thì việc tự xoá là gì?
Lời giải — tái hiện:
redis-cli CONFIG SET maxmemory 10mb
redis-cli CONFIG SET maxmemory-policy noeviction
redis-cli --pipe < <(for i in $(seq 1 200000); do
echo "SET key:$i $(head -c 100 /dev/zero | tr '\0' 'x')"
done)(error) OOM command not allowed when used memory > 'maxmemory'.Trong ứng dụng:
StackExchange.Redis.RedisServerException: OOM command not allowed when used
memory > 'maxmemory'.
at StackExchange.Redis.ConnectionMultiplexer.ExecuteSyncImpl[T](...)Và đây là chỗ nó thành sự cố toàn hệ thống: nếu code cache không bắt exception (bài 14.3), mọi request chạm cache đều trả 500 — dù database hoàn toàn khoẻ.
Redis đầy -> mọi lệnh GHI thất bại
-> nếu code không chịu lỗi: mọi request lỗi
-> nếu có chịu lỗi: cache miss 100% -> toàn bộ tải dội vào database
-> database quá tải
-> sự cố lan sang cả những phần không dùng cacheChú ý: lệnh đọc vẫn hoạt động khi đầy bộ nhớ với noeviction — chỉ lệnh ghi bị từ chối. Điều đó làm triệu chứng khó hiểu hơn: một phần request vẫn chạy bình thường (những request trúng cache), phần khác thì lỗi.
Vì sao noeviction là mặc định. Vì Redis không chỉ là cache. Nó được dùng làm:
Cache -> mất dữ liệu: không sao, tính lại được
Hàng đợi công việc -> mất dữ liệu: MẤT CÔNG VIỆC
Session store -> mất dữ liệu: người dùng bị đăng xuất
Distributed lock -> mất dữ liệu: HAI TIẾN TRÌNH CÙNG CHẠY
Rate limiter -> mất dữ liệu: giới hạn bị bỏ qua
Pub/sub, stream -> mất dữ liệu: mất sự kiện
Cơ sở dữ liệu chính -> mất dữ liệu: mất dữ liệu nghiệp vụVới bảy trong tám trường hợp trên, tự động xoá dữ liệu là hành vi phá hoại. Một khoá phân tán bị evict nghĩa là hai instance cùng chạy job — đúng sự cố ở bài 14.11, lần này do Redis gây ra.
Redis không biết bạn đang dùng nó cho việc gì, nên nó chọn mặc định an toàn nhất: thà từ chối ghi và báo lỗi rõ ràng, còn hơn im lặng xoá thứ có thể là dữ liệu duy nhất.
Nguyên tắc chung: khi không biết, thất bại ồn ào tốt hơn mất mát im lặng.
Chọn chính sách cho Redis dùng làm cache:
redis-cli CONFIG SET maxmemory-policy allkeys-lruTám chính sách:
| Chính sách | Xoá gì | Dùng cho |
|---|---|---|
noeviction | Không xoá, từ chối ghi | Hàng đợi, khoá, session, database chính |
allkeys-lru | Khoá ít dùng gần đây nhất, mọi khoá | Cache thuần tuý |
allkeys-lfu | Khoá ít dùng thường xuyên nhất | Cache có điểm nóng rõ rệt |
allkeys-random | Ngẫu nhiên, mọi khoá | Truy cập đều, không có điểm nóng |
volatile-lru | LRU, chỉ khoá có TTL | Redis dùng lẫn cache và dữ liệu bền |
volatile-lfu | LFU, chỉ khoá có TTL | Như trên |
volatile-ttl | Khoá sắp hết hạn nhất | Như trên |
volatile-random | Ngẫu nhiên, chỉ khoá có TTL | Như trên |
allkeys-lru cho Redis chỉ dùng làm cache — đơn giản, dễ đoán, và phù hợp với phần lớn mẫu truy cập.
allkeys-lfu khi có điểm nóng rõ rệt. Khác biệt đáng chú ý:
LRU: xét "lần cuối dùng khi nào"
-> một lần quét toàn bộ dữ liệu sẽ ĐẨY HẾT điểm nóng ra ngoài
LFU: xét "dùng bao nhiêu lần"
-> điểm nóng sống sót qua một lần quétNếu hệ thống của bạn có job báo cáo đọc qua toàn bộ dữ liệu mỗi đêm, LRU sẽ làm rỗng cache mỗi đêm và buổi sáng bắt đầu với tỷ lệ trúng bằng 0. LFU không bị vậy.
volatile-* nguy hiểm hơn vẻ ngoài. Nó chỉ xoá khoá có TTL. Nếu mọi khoá đều không TTL, Redis không có gì để xoá và hành xử y hệt noeviction:
maxmemory-policy = volatile-lru
1.000.000 khoá, không khoá nào có TTL
-> Redis đầy
-> không có ứng viên nào để xoá
-> (error) OOM command not allowedĐây là bẫy phổ biến: người ta đặt volatile-lru tin rằng đã an toàn, rồi quên rằng nó phụ thuộc vào việc mọi khoá cache đều có TTL — và bài 2 sẽ cho thấy điều đó hiếm khi đúng.
Cấu hình đầy đủ cho Redis làm cache:
maxmemory 2gb
maxmemory-policy allkeys-lru
maxmemory-samples 10 # lấy mẫu nhiều hơn -> LRU chính xác hơn, tốn CPU hơnmaxmemory phải nhỏ hơn RAM thật, vì Redis còn cần bộ nhớ cho buffer client, buffer replication và phân mảnh:
Máy 4 GB RAM -> maxmemory khoảng 2,5–3 GBĐặt maxmemory bằng RAM thật sẽ khiến hệ điều hành giết tiến trình Redis (OOM killer) trước khi chính sách eviction kịp có tác dụng — và lúc đó bạn mất toàn bộ dữ liệu trong Redis, không chỉ những khoá ít dùng.
Tách Redis theo mục đích — đây là khuyến nghị quan trọng nhất của bài:
Redis instance 1 — cache: allkeys-lru, maxmemory 4gb
Redis instance 2 — hàng đợi/khoá: noeviction, maxmemory 1gb, bật AOFKhông thể có một chính sách đúng cho cả hai mục đích. Dùng chung một instance nghĩa là bạn phải chọn: hoặc cache không được evict (và gây OOM), hoặc khoá phân tán có thể bị evict (và gây chạy trùng).
Nếu không tách được instance, ít nhất hãy tách database trong cùng instance và đặt volatile-lru với kỷ luật TTL nghiêm ngặt cho phần cache:
var cacheDb = _redis.GetDatabase(0);
var khoaDb = _redis.GetDatabase(1);Lưu ý rằng maxmemory-policy áp cho cả instance, không theo database — nên cách này chỉ giúp tách logic, không tách được chính sách. Tách instance vẫn là lựa chọn đúng.
Bài 2 — Tìm khoá không có TTL
Chạy script đếm khoá có TTL = -1 trên môi trường thật.
Tiêu chí hoàn thành: bạn dùng SCAN chứ không dùng KEYS, và phân loại được khoá nào nên không có TTL.
Gợi ý và lời giải — Bài 2
Gợi ý. Bài 14.3 đã nói vì sao không được dùng KEYS trên production.
Lời giải — script an toàn:
#!/usr/bin/env bash
# Đếm khoá không có TTL, theo tiền tố. Dùng SCAN nên không chặn Redis.
redis-cli --scan --pattern '*' --count 100 | while read -r khoa; do
if [ "$(redis-cli TTL "$khoa")" = "-1" ]; then
echo "$khoa"
fi
done | awk -F: '{print $1}' | sort | uniq -c | sort -rn 412038 lead
18204 session
3021 bao-cao
4 config
2 feature-flagScript trên gọi TTL cho từng khoá — chậm với hàng triệu khoá. Phiên bản nhanh hơn dùng pipeline:
var server = _redis.GetServer(_redis.GetEndPoints().First());
var db = _redis.GetDatabase();
var thongKe = new Dictionary<string, (int Tong, int KhongTtl, long Byte)>();
await foreach (var khoa in server.KeysAsync(pageSize: 1000))
{
var ttl = await db.KeyTimeToLiveAsync(khoa);
var kichThuoc = await db.ExecuteAsync("MEMORY", "USAGE", khoa.ToString());
var tienTo = khoa.ToString().Split(':')[0];
var cu = thongKe.GetValueOrDefault(tienTo);
thongKe[tienTo] = (cu.Tong + 1,
cu.KhongTtl + (ttl is null ? 1 : 0),
cu.Byte + (long)kichThuoc);
}
foreach (var (tienTo, x) in thongKe.OrderByDescending(x => x.Value.KhongTtl))
Console.WriteLine($"{tienTo,-20} {x.Tong,8} khoá, {x.KhongTtl,8} không TTL, " +
$"{x.Byte / 1024.0 / 1024:F1} MB");lead 412038 khoá, 412038 không TTL, 1840.2 MB
session 18204 khoá, 18204 không TTL, 42.1 MB
bao-cao 3021 khoá, 0 không TTL, 128.4 MB
config 4 khoá, 4 không TTL, 0.1 MBTiền tố lead chiếm 1,8 GB và không khoá nào có TTL — đây là nguyên nhân của sự cố.
Phân loại: khoá nào nên không có TTL:
| Loại khoá | Nên có TTL | Vì sao |
|---|---|---|
| Cache kết quả truy vấn | Có — luôn luôn | Dữ liệu có thể cũ; TTL là lưới an toàn |
| Session người dùng | Có | Session phải hết hạn vì lý do bảo mật |
| Rate limit counter | Có — bằng cửa sổ | Hết cửa sổ thì bộ đếm vô nghĩa |
| Khoá phân tán | Có — bắt buộc | Không TTL thì khoá kẹt vĩnh viễn khi tiến trình chết |
| Idempotency key | Có — 24 giờ | Quá thời gian retry hợp lý thì không cần nữa |
| Hàng đợi công việc | Không | Công việc chưa làm thì không được biến mất |
| Cấu hình, feature flag | Không | Là nguồn dữ liệu, không phải cache |
| Stream, pub/sub state | Không | Quản lý bằng MAXLEN, không bằng TTL |
Bốn dòng cuối là những thứ đúng khi không có TTL — và chúng cũng chính là lý do không nên đặt volatile-* rồi yên tâm: những khoá này không có TTL nên sẽ không bao giờ bị evict, nhưng cache thì có, nên chính sách vẫn hoạt động. Vấn đề chỉ xuất hiện khi cache cũng không có TTL, như tiền tố lead ở trên.
Sửa gốc — ép TTL ở tầng cache, không dựa vào việc nhớ:
public class CacheCoTtlBatBuoc : ICacheService
{
private static readonly TimeSpan TtlMacDinh = TimeSpan.FromMinutes(30);
private static readonly TimeSpan TtlToiDa = TimeSpan.FromHours(24);
public async Task LuuAsync<T>(string khoa, T giaTri, TimeSpan? ttl, CancellationToken ct)
{
var thoiHan = ttl ?? TtlMacDinh;
if (thoiHan > TtlToiDa)
throw new ArgumentException($"TTL không được vượt {TtlToiDa}", nameof(ttl));
await _db.StringSetAsync(khoa, Serialize(giaTri), thoiHan);
}
}Không có đường nào để lưu một khoá không TTL qua lớp này. Kèm analyzer cấm gọi IDatabase.StringSetAsync trực tiếp ngoài lớp này, và vấn đề được loại bỏ về mặt cấu trúc.
Chữa cháy — thêm TTL cho khoá đã tồn tại:
#!/usr/bin/env bash
# Thêm TTL cho khoá có tiền tố cho trước mà chưa có TTL
TIEN_TO="$1"
TTL="${2:-1800}"
DEM=0
redis-cli --scan --pattern "${TIEN_TO}:*" --count 100 | while read -r khoa; do
if [ "$(redis-cli TTL "$khoa")" = "-1" ]; then
redis-cli EXPIRE "$khoa" "$TTL" > /dev/null
DEM=$((DEM + 1))
[ $((DEM % 1000)) -eq 0 ] && echo "Đã xử lý $DEM khoá"
fi
done./them-ttl.sh lead 1800Chạy nó có kiểm soát tốc độ trên production — đặt TTL cho 400.000 khoá cùng lúc nghĩa là 400.000 khoá sẽ hết hạn gần như cùng lúc, và bạn có một cơn stampede. Rải TTL ra:
TTL=$((1800 + RANDOM % 1800)) # 30–60 phút, ngẫu nhiên từng khoáGiám sát định kỳ:
public class KiemTraTtlJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(ct))
{
var khongTtl = await DemKhoaKhongTtlAsync(ct);
_meter.Record(khongTtl, new KeyValuePair<string, object?>("loai", "khong_ttl"));
if (khongTtl > NguongChoPhep)
_logger.LogWarning("Có {SoLuong} khoá không TTL, vượt ngưỡng {Nguong}",
khongTtl, NguongChoPhep);
}
}
}Đặt NguongChoPhep bằng số khoá được phép không TTL (cấu hình, feature flag, hàng đợi). Mọi thứ vượt lên trên là khoá cache bị sót.
Và ba lệnh nên chạy trước mỗi lần rà soát Redis:
# 1. Tổng quan bộ nhớ
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|maxmemory_policy|mem_fragmentation_ratio"
# 2. Số khoá và số khoá có TTL, theo database
redis-cli INFO keyspace
# db0:keys=433267,expires=3021,avg_ttl=1793244
# ^^^^^^ ^^^^ chỉ 0,7% khoá có TTL -> dấu hiệu rõ ràng
# 3. Các khoá lớn nhất
redis-cli --bigkeysDòng thứ hai là chẩn đoán nhanh nhất: tỷ lệ expires / keys dưới 50% trên một Redis dùng làm cache gần như luôn nghĩa là có một đường ghi đang quên TTL.
Bài 3 — Thử chịu lỗi
Tắt Redis và xác nhận ứng dụng vẫn phục vụ được, chỉ chậm hơn.
Tiêu chí hoàn thành: bạn đo được mức suy giảm thật khi mất cache, và nêu được cách chuẩn bị cho trường hợp mức đó là không chấp nhận được.
Gợi ý và lời giải — Bài 3
Gợi ý. Cache trúng 95% nghĩa là database đang nhận 5% tải. Mất cache thì database nhận bao nhiêu?
Lời giải — thử:
# Đo trạng thái bình thường
ab -n 1000 -c 20 http://localhost:5000/leads
# Requests per second: 842
# p95: 34 ms
docker stop redis
ab -n 1000 -c 20 http://localhost:5000/leads
# Requests per second: 47
# p95: 620 msThroughput giảm 18 lần, p95 tăng 18 lần.
Ứng dụng VẪN phục vụ được — nhưng với 5,6% năng lực.Tính trước mức suy giảm:
Tỷ lệ trúng cache: 95%
Tải database bình thường: 5% tổng số request
Khi mất cache: 100% tổng số request
-> Tải database tăng 1 / 0,05 = 20 LẦNĐây là con số quan trọng nhất của bài, và nó phản trực giác: cache càng hiệu quả, mất cache càng nguy hiểm.
| Tỷ lệ trúng | Tải database khi mất cache |
|---|---|
| 50% | 2 lần |
| 80% | 5 lần |
| 90% | 10 lần |
| 95% | 20 lần |
| 99% | 100 lần |
Một cache trúng 99% — thứ mà ai cũng coi là thành tích — nghĩa là database chỉ được thiết kế cho 1% tải thật. Mất cache là tải tăng gấp trăm, và không database nào chịu được điều đó.
Nghịch lý: cache càng tốt, hệ thống càng PHỤ THUỘC vào nó,
và càng ít có khả năng sống sót khi mất nó.Ba kịch bản sau khi mất cache:
Kịch bản A — database chịu được 20 lần tải
-> ứng dụng chậm nhưng hoạt động
-> đây là điều bạn muốn, và phải KIỂM CHỨNG chứ không giả định
Kịch bản B — database quá tải
-> truy vấn chậm dần
-> connection pool cạn
-> request timeout
-> ứng dụng NGỪNG HOẠT ĐỘNG dù đã bắt exception của cache
Kịch bản C — database quá tải, và retry làm nó nặng thêm
-> đúng vòng lặp ở bài 14.9
-> hệ thống không tự hồi phục kể cả khi Redis sống lạiBài tập này thật ra đang hỏi: bạn đang ở kịch bản nào? Và câu trả lời chỉ có được bằng cách thử.
Chuẩn bị cho trường hợp mức suy giảm không chấp nhận được — năm biện pháp:
1. Cache L1 trong bộ nhớ làm lớp dự phòng. HybridCache cho bạn điều này miễn phí:
builder.Services.AddHybridCache(o =>
{
o.DefaultEntryOptions = new HybridCacheEntryOptions
{
LocalCacheExpiration = TimeSpan.FromMinutes(2), // L1
Expiration = TimeSpan.FromMinutes(30), // L2 (Redis)
};
});Khi Redis chết, L1 vẫn phục vụ được phần lớn lượt đọc trong hai phút đầu — đủ để Redis khởi động lại, hoặc đủ để bạn nhận cảnh báo và hành động. Đây là biện pháp rẻ nhất và hiệu quả nhất trong năm cái.
2. Giới hạn tải xuống database — thà từ chối một phần còn hơn sập cả:
var gioiHan = new ConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 50,
QueueLimit = 100,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
});
using var lease = await gioiHan.AcquireAsync(1, ct);
if (!lease.IsAcquired)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);Đây là suy giảm có kiểm soát: 50 request được phục vụ tốt, phần còn lại nhận 503 ngay lập tức thay vì tất cả cùng timeout sau 30 giây.
3. Ưu tiên đường nghiệp vụ quan trọng. Không phải mọi endpoint đều đáng như nhau:
// Dashboard, báo cáo — tắt được khi hệ thống đang khó khăn
if (_featureFlags.HeThongDangQuaTai) return Results.StatusCode(503);
// Tạo đơn hàng, thanh toán — luôn phục vụ4. Redis có tính sẵn sàng cao, nếu mức suy giảm thật sự không chấp nhận được:
Redis Sentinel: một master, nhiều replica, tự bầu master mới khi master chết
Redis Cluster: chia dữ liệu theo slot, mỗi slot có replica
Managed Redis: Azure Cache, AWS ElastiCache — nhà cung cấp lo failoverLưu ý: failover mất vài giây tới vài chục giây, và trong khoảng đó ứng dụng vẫn cần chịu được mất cache. HA giảm tần suất và thời lượng, không loại bỏ nhu cầu ở mục 1 và 2.
5. Diễn tập định kỳ. Đây là biện pháp mà ít nhóm làm và nó quan trọng nhất:
Mỗi tháng, trên staging có dữ liệu và tải gần giống production:
1. Tắt Redis
2. Đo throughput, p95, tỷ lệ lỗi
3. Kiểm tra health check trả Degraded chứ không phải Unhealthy
4. Kiểm tra log có cảnh báo rõ ràng, không phải một biển exception
5. Bật lại Redis, đo thời gian hồi phụcKhông diễn tập thì mọi thứ ở trên chỉ là giả định — và giả định về hành vi khi hỏng hóc gần như luôn sai. Hai chỗ hay sai nhất: một try/catch bị sót ở đường code ít dùng, và một health check cấu hình là Unhealthy khiến Kubernetes rút hết pod (bài 14.3).
Và ghi lại con số, để nó là một sự thật được biết chứ không phải một bất ngờ:
## Khả năng chịu lỗi — Redis
Đo ngày 2026-09-25 trên staging, dữ liệu 80% kích thước production.
| Chỉ số | Bình thường | Không Redis | Tỷ lệ |
|---|---:|---:|---:|
| Throughput | 842 req/s | 47 req/s | 5,6% |
| p95 | 34 ms | 620 ms | 18× |
| Tải database | 5% | 100% | 20× |
| Tỷ lệ lỗi | 0,01% | 0,3% | — |
Kết luận: chịu được khoảng 15 phút mất Redis ở mức tải thấp điểm.
Ở giờ cao điểm, database sẽ quá tải sau khoảng 3 phút.
Hành động: đã bật HybridCache với L1 TTL 2 phút; mục tiêu quý sau là
dựng Redis Sentinel.Một bảng như vậy là thứ giúp người trực ca đêm quyết định đúng trong năm phút đầu của sự cố — và nó chỉ tồn tại nếu bạn đã làm bài tập này.
Tự kiểm tra
Vì sao maxmemory-policy mặc định gây sự cố?
Vì mặc định là noeviction, nghĩa là khi đầy bộ nhớ Redis từ chối mọi lệnh ghi thay vì xoá bớt khoá cũ. Ứng dụng nhận lỗi OOM ở mỗi lần ghi cache.
Vì sao lỗi ghi cache lại làm hỏng cả request?
Vì code không bọc lời gọi cache trong try catch. Dữ liệu đã được lấy thành công từ database, chỉ có bước ghi cache thất bại, nhưng exception thoát ra làm cả request trả về lỗi 500.
Nguyên nhân gốc của việc Redis đầy là gì?
Khoá không có TTL. Chúng không bao giờ biến mất nên Redis đầy dần suốt nhiều tháng cho tới khi chạm trần. Lệnh TTL trả về -1 nghĩa là khoá tồn tại nhưng không có hạn.
Chọn maxmemory-policy thế nào?
Nếu Redis chỉ dùng làm cache thì allkeys-lru, vì mất một khoá chỉ là cache miss. Nếu nó chứa cả dữ liệu không được mất thì volatile-lru, nhưng cần cẩn thận vì nếu không khoá nào có TTL thì nó hành xử như noeviction.
Vì sao AbortOnConnectFail nên đặt false?
Vì mặc định, nếu Redis chưa sẵn sàng lúc ứng dụng khởi động thì thư viện ném exception và ứng dụng không khởi động được. Điều đó biến một phụ thuộc tuỳ chọn thành phụ thuộc bắt buộc.
Tỷ lệ cache hit dưới 50% nghĩa là gì?
Phần lớn lần đọc cache đều miss, nên bạn trả chi phí một lượt đi về Redis mà vẫn phải truy vấn database. Khi đó cache có thể đang gây hại nhiều hơn giúp, và nên xem lại dữ liệu đó có đáng cache không.
Kết luận
Ba điều đáng nhớ nhất:
maxmemory-policymặc định lànoeviction— đặt nó tường minh ngay.- Mọi lần ghi cache phải có TTL; bắt buộc nó trong interface của bạn.
- Redis chết thì hệ thống chậm, không phải hệ thống sập.
Tham khảo
Điều hướng
- Bài trước: 14.11 — Mini case study
- Bài tiếp theo: 14.13 — Review and Assessment
- Về module: Trang mục lục