Skip to content

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-policy mặc định là noeviction, nó từ chối mọi lệnh ghi thay vì xoá bớt khoá cũ. Ứng dụng nhận OOM command not allowed ở mỗi lần ghi cache — và vì code không bọc lời gọi cache trong try/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)
csharp
// 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 ​

bash
redis-cli INFO memory
# used_memory_human:7.98G
# maxmemory_human:8.00G
# maxmemory_policy:noeviction        <- MẶC ĐỊNH
bash
# Đế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
csharp
// 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 TTL

Khoá 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 ​

bash
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áchHành vi khi đầy
noevictionTừ chối ghi — mặc định, gây sự cố này
allkeys-lruXoá khoá ít dùng gần đây nhất — tốt cho cache thuần
volatile-lruChỉ xoá khoá có TTL
allkeys-lfuXoá 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 ​

csharp
// 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.

csharp
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:

csharp
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:

csharp
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 ​

bash
# 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ệ hitNghĩ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:

bash
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)
text
(error) OOM command not allowed when used memory > 'maxmemory'.

Trong ứng dụng:

text
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ẻ.

text
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 cache

Chú ý: 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:

text
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:

bash
redis-cli CONFIG SET maxmemory-policy allkeys-lru

Tám chính sách:

Chính sáchXoá gìDùng cho
noevictionKhông xoá, từ chối ghiHàng đợi, khoá, session, database chính
allkeys-lruKhoá ít dùng gần đây nhất, mọi khoáCache thuần tuý
allkeys-lfuKhoá ít dùng thường xuyên nhấtCache có điểm nóng rõ rệt
allkeys-randomNgẫu nhiên, mọi khoáTruy cập đều, không có điểm nóng
volatile-lruLRU, chỉ khoá có TTLRedis dùng lẫn cache và dữ liệu bền
volatile-lfuLFU, chỉ khoá có TTLNhư trên
volatile-ttlKhoá sắp hết hạn nhấtNhư trên
volatile-randomNgẫu nhiên, chỉ khoá có TTLNhư 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ú ý:

text
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ét

Nế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:

text
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:

conf
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ơn

maxmemory 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:

text
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:

text
Redis instance 1 — cache:        allkeys-lru,   maxmemory 4gb
Redis instance 2 — hàng đợi/khoá: noeviction,   maxmemory 1gb, bật AOF

Khô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:

csharp
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:

bash
#!/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
text
 412038 lead
  18204 session
   3021 bao-cao
      4 config
      2 feature-flag

Script 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:

csharp
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");
text
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 MB

Tiề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ó TTLVì sao
Cache kết quả truy vấnCó — luôn luônDữ liệu có thể cũ; TTL là lưới an toàn
Session người dùngCóSession phải hết hạn vì lý do bảo mật
Rate limit counterCó — bằng cửa sổHết cửa sổ thì bộ đếm vô nghĩa
Khoá phân tánCó — bắt buộcKhông TTL thì khoá kẹt vĩnh viễn khi tiến trình chết
Idempotency keyCó — 24 giờQuá thời gian retry hợp lý thì không cần nữa
Hàng đợi công việcKhôngCông việc chưa làm thì không được biến mất
Cấu hình, feature flagKhôngLà nguồn dữ liệu, không phải cache
Stream, pub/sub stateKhôngQuả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ớ:

csharp
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:

bash
#!/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
bash
./them-ttl.sh lead 1800

Chạ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:

bash
TTL=$((1800 + RANDOM % 1800))     # 30–60 phút, ngẫu nhiên từng khoá

Giám sát định kỳ:

csharp
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:

bash
# 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 --bigkeys

Dò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ử:

bash
# Đ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 ms
text
Throughput 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:

text
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úngTả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 đó.

text
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:

text
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ại

Bà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í:

csharp
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ả:

csharp
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:

csharp
// 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:

text
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 failover

Lư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:

text
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ục

Khô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ờ:

markdown
## 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:

  1. maxmemory-policy mặc định là noeviction — đặt nó tường minh ngay.
  2. Mọi lần ghi cache phải có TTL; bắt buộc nó trong interface của bạn.
  3. 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 ​

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