Phần trước coi TTL là thứ duy nhất quyết định khi nào một key rời khỏi cache. Giả định đó chỉ đúng khi cache luôn đủ chỗ. Thực tế Redis có một lượng RAM hữu hạn, và khi chạm trần, nó phải đá bớt key ra — trước khi TTL của chúng hết hạn. Lúc ấy câu hỏi không còn là "cache bao lâu" mà là "đá key nào", và câu trả lời mặc định không phải lúc nào cũng đúng.

Hai núm quyết định mọi thứ khi cache đầy

Redis chỉ có hai tham số cho chuyện này: maxmemory (trần RAM) và maxmemory-policy (đá key nào khi chạm trần).

Ảnh chụp đoạn mã nền tối khi cache đầy key bị đá trước cả khi TTL hết với Redis maxmemory và maxmemory-policy trong kịch bản job quét làm bẩn cache, hai núm duy nhất quyết định chuyện gì xảy ra lúc đầy là redis-cli CONFIG SET maxmemory 2mb với 0 nghĩa là không giới hạn và redis-cli CONFIG SET maxmemory-policy allkeys-lfu, các chính sách allkeys-lru đá key lâu không đọc nhất là cái người ta hay chọn, allkeys-lfu đá key ít được đọc nhất đếm tần suất có suy giảm, allkeys-random đá đại một key, volatile sao chỉ đá key có TTL nên key không TTL thì bất tử, noeviction không đá gì mà GHI bị từ chối đây là mặc định của Redis, kịch bản đo một job quét đi qua toàn bộ dữ liệu gồm bước 1 làm nóng 20000 lượt đọc dồn vào 200 key nóng, bước 2 một job quét tuần tự hết 2000 key mỗi key đúng một lần như báo cáo crawler backfill, bước 3 lưu lượng thật quay lại 5000 lượt đọc chỉ key nóng, câu hỏi là sau cú quét đó 200 key nóng còn sống được bao nhiêu

Hình 1: Năm nhóm chính sách và kịch bản đo. Điểm mấu chốt của kịch bản: job quét đọc mỗi key đúng một lần — nó làm mọi key trông "vừa mới được dùng" với LRU, nhưng vẫn là "ít được dùng" với LFU.

Hai điều đáng nhớ ngay từ bảng chính sách:

Mặc định của Redis là noeviction, không phải LRU. Nhiều người tưởng Redis tự dọn khi đầy. Không — mặc định nó từ chối ghi. Nếu bạn chạy Redis làm cache mà chưa đụng tới maxmemory-policy, bạn đang chạy đúng cấu hình tệ nhất cho một cache.

volatile-* chỉ đá key có TTL. Nghe hợp lý cho tới khi ai đó SET một key quên TTL — key đó trở nên bất tử, không bao giờ bị đá, và nếu có nhiều key như vậy thì Redis hết chỗ mà chẳng còn gì đá được. Đây là một lý do nữa để TTL là bắt buộc như đã nói ở phần 2.

Đo thật: một job quét xoá sạch key nóng dưới LRU

Kịch bản này mô phỏng chuyện xảy ra hàng ngày ở hệ thống thật: cache đang phục vụ tốt cho một tập key nóng, rồi một job chạy nền — xuất báo cáo, backfill, crawler, một lần SELECT quét cả bảng rồi warm cache — đi qua toàn bộ dữ liệu đúng một lượt. Job đó không hề "nóng": nó đụng mỗi key một lần rồi thôi. Nhưng với LRU, "vừa đụng một lần" và "được đụng hai mươi nghìn lần" trông giống hệt nhau: cả hai đều là vừa mới dùng.

Mình đặt 2.000 key × 1KB (Redis báo 3,78 MB khi chứa đủ), siết maxmemory xuống 2,00 MB — chỉ đủ khoảng 30% tập làm việc — rồi chạy ba giai đoạn: làm nóng 200 key, quét một lượt 2.000 key, đo lại 200 key nóng.

Ảnh chụp output thật nền tối LFU giữ được key nóng còn LRU thì không trên Redis 7 với 2000 key nhân 1KB bằng 3.78 MB và maxmemory 2.00 MB khoảng 30 phần trăm, bảng sau một job quét tuần tự qua toàn bộ 2000 key, allkeys-lru key nóng từ 200 trên 200 còn 18 trên 200 bị đá khi quét 1592 key và 5000 lượt đọc sau đó có 187 miss hit ratio 96.3 phần trăm, allkeys-lfu từ 200 trên 200 còn 199 trên 200 bị đá 1690 key và chỉ 1 miss hit ratio 100.0 phần trăm, allkeys-random từ 200 trên 200 còn 11 trên 200 bị đá 1637 key và 227 miss hit ratio 95.5 phần trăm, chạy 3 lần LRU giữ 18 đến 34 trên 200 LFU giữ 192 đến 199 trên 200 random giữ 11 đến 21 trên 200 nên một cú quét tuần tự xoá sạch tập key nóng dưới LRU còn LFU gần như không suy suyển, noeviction không đá key nào nên ghi hỏng với 1400 key ghi thêm khi đã đầy thì 8 thành công và 1392 bị từ chối lỗi đầu tiên là OOM command not allowed when used memory lớn hơn maxmemory nghĩa là đọc vẫn chạy bình thường nhưng mọi lệnh ghi kể cả bước điền cache của cache-aside đều hỏng và cache đóng băng ở nội dung cũ, nói thẳng hit ratio sau quét của LRU vẫn 96,3 phần trăm vì 187 lượt miss được điền lại ngay nên cái mất không phải tỉ lệ mà là 187 truy vấn database dồn vào ngay sau khi job quét chạy xong

Hình 2: Kết quả thật. Cột quan trọng nhất là "key nóng còn sống sau quét": LRU 18/200, random 11/200, LFU 199/200. Chạy lại ba lần cho dải 18–34, 11–21 và 192–199 — kết luận không đổi.

  • LRU mất 91% tập key nóng chỉ vì một lượt quét. Từ 200/200 xuống 18/200. Lý do nằm đúng ở định nghĩa của LRU: nó xếp hạng theo lần cuối được đọc, và job quét vừa chạm vào cả 2.000 key, nên 1.800 key lạnh bỗng trở thành "mới dùng nhất" và đá văng 182 key nóng. Hiện tượng này có tên riêng — cache pollution (làm bẩn cache) — và nó là điểm yếu cố hữu của LRU, không phải bug.
  • LFU gần như miễn nhiễm: 199/200. LFU xếp hạng theo số lần được đọc, và một lượt quét chỉ cộng 1 cho mỗi key lạnh, trong khi key nóng đã tích luỹ hàng nghìn lượt. Một lần chạm không đủ để lật đổ thứ hạng. Đây chính là tình huống LFU sinh ra để giải quyết.
  • Random tệ hơn LRU một chút (11/200) và đáng nhắc vì nó cho thấy LRU có làm được việc gì đó — chỉ là không đủ trong kịch bản này.
  • Số key bị đá gần bằng nhau (1.592 / 1.690 / 1.637). Ba chính sách đá cùng một lượng — trần RAM quyết định số lượng, chính sách chỉ quyết định đá đúng key hay đá nhầm key. Nhìn evicted_keys trong INFO stats không cho biết chính sách có phù hợp hay không; phải nhìn hit ratio của tập key nóng.

Và đây là chỗ phải nói thẳng, vì nó dễ bị đọc nhầm thành "LRU làm hit ratio sụp": hit ratio sau quét của LRU vẫn 96,3%. Con số đó cao vì cache-aside điền lại ngay sau mỗi miss, nên 5.000 lượt đọc chỉ chịu 187 miss ở đầu rồi êm trở lại. Cái mất không phải tỉ lệ, mà là 187 truy vấn database dồn vào đúng khoảnh khắc ngay sau khi job quét chạy xong — một gai tải định kỳ, đúng cỡ tập key nóng, lặp lại mỗi lần job chạy. Nếu tập nóng của bạn là 200.000 key chứ không phải 200, con số đó là 200.000 query trong vài giây.

noeviction: đọc vẫn chạy, ghi chết lặng

Vì noeviction là mặc định của Redis, cần biết chính xác nó hỏng như thế nào. Mình nạp 600 key, siết maxmemory, rồi ghi thêm 1.400 key:

  • 1.392/1.400 lệnh ghi bị từ chối, lỗi OOM command not allowed when used memory > 'maxmemory'.
  • 8 lệnh lọt qua là do dao động bộ nhớ trong của Redis ngay quanh ngưỡng, không phải chỗ còn trống thật.

Hình dạng của sự cố này rất khó chịu: đọc hoàn toàn bình thường, GET vẫn trả dữ liệu, hit ratio vẫn đẹp. Chỉ có ghi hỏng — tức là bước 3 của cache-aside, bước điền cache. Cache đóng băng ở đúng nội dung nó đang có: key mới không bao giờ vào được, và sau khi TTL của key cũ hết, mọi request rơi thẳng xuống database vĩnh viễn. Nếu ứng dụng nuốt lỗi của Set (rất dễ — nhiều code viết rdb.Set(...) không kiểm tra lỗi, kể cả đoạn ở phần 2), thì không có một tín hiệu nào báo chuyện này đang xảy ra ngoài việc tải database tăng dần.

Đánh đổi cần cân nhắc

LFU không phải lúc nào cũng thắng — nó bảo thủ với dữ liệu nóng. Kịch bản này được dựng đúng vào thế mạnh của LFU: tập nóng ổn định, bị một lượt quét làm phiền. Nếu độ nóng của bạn trôi theo thời gian — tin tức, bài mới, sản phẩm theo mùa — thì LFU lại bám vào key từng nóng hồi trước và chậm nhường chỗ cho key đang nóng. Redis có lfu-decay-time (mặc định 1 phút) để làm suy giảm bộ đếm, và tham số đó mới là thứ quyết định LFU thích ứng nhanh hay chậm. LRU thì thích ứng tức thì — đó vừa là điểm yếu (với job quét) vừa là điểm mạnh (với độ nóng trôi).

LRU và LFU của Redis đều là xấp xỉ, không phải chính xác. Redis không duy trì danh sách LRU đầy đủ (quá tốn bộ nhớ); nó lấy mẫu ngẫu nhiên maxmemory-samples key (mặc định 5) rồi đá cái tệ nhất trong mẫu. Đây là lý do số đo dao động giữa các lần chạy (18–34/200), và cũng là lý do đừng mong hành vi tất định. Tăng maxmemory-samples cho kết quả gần lý thuyết hơn, đổi lại tốn CPU mỗi lần đá.

Chính sách không sửa được việc cache quá nhỏ. Thí nghiệm này đặt maxmemory bằng 30% tập làm việc — một cấu hình kham khổ có chủ ý để hiện tượng lộ rõ. Nếu cache của bạn chứa thoải mái cả tập nóng lẫn dữ liệu mà job quét đụng tới, thì cả ba chính sách đều ổn và việc chọn giữa chúng là bàn chuyện bên lề. Đo used_memory so với maxmemory và evicted_keys trước; nếu evicted_keys tăng liên tục thì vấn đề là dung lượng, và đổi chính sách chỉ là chọn ai được sống trong một căn nhà quá chật.

Số đo ở đây là một container, một client, giá trị đều 1KB. Với giá trị kích thước không đều, số key đá được cho mỗi lần giải phóng sẽ khác, và overhead bộ nhớ của Redis (khoảng 0,9 MB cho 2.000 key ở phép đo này, tức ~45% trên dữ liệu thuần) cũng đổi theo cấu trúc dữ liệu bạn dùng. Cơ chế — LRU gục trước quét tuần tự, LFU chống được, noeviction giết đường ghi — đúng ở mọi quy mô.

Ba ý mang về

  1. Khi chạm maxmemory, key bị đá trước khi TTL hết, và chính sách quyết định đá đúng hay đá nhầm. Ba chính sách đá gần như cùng số key (1.592/1.690/1.637) — evicted_keys không nói lên chính sách có phù hợp không; phải đo hit ratio của riêng tập key nóng.
  2. Một job quét tuần tự xoá sạch key nóng dưới LRU (còn 18/200) vì "vừa đụng một lần" và "đụng hai mươi nghìn lần" là như nhau với LRU. LFU giữ 199/200. Nếu hệ của bạn có báo cáo, backfill hay crawler chạy nền, allkeys-lfu là lựa chọn mặc định tốt hơn — nhưng cân nhắc lfu-decay-time nếu độ nóng trôi theo thời gian.
  3. noeviction là mặc định của Redis và là cấu hình tệ nhất cho một cache: 1.392/1.400 lệnh ghi bị từ chối với OOM command not allowed, trong khi đọc vẫn chạy ngon lành — cache đóng băng mà không có tín hiệu nào, trừ khi bạn thực sự kiểm tra lỗi trả về của Set.

Nguồn

Phần sau ta quay lại khoảnh khắc một key vừa biến mất — dù do TTL hết hay do bị đá — và hàng trăm request cùng miss nó trong cùng một phần nghìn giây. Đo thật cơn stampede: bao nhiêu goroutine cùng lao xuống database cho đúng một key.