Giao diện
Sự cố thường gặp
| Sự cố | Chuyện gì xảy ra | Cách xử lý chính |
|---|---|---|
| Avalanche | Rất nhiều key hết hạn cùng lúc, hoặc cache sập | TTL có jitter, cache sẵn sàng cao, nạp trước |
| Thundering herd | Một key nóng hết hạn, cả đàn request cùng xuống database | Gom request, lock, trả dữ liệu cũ, làm mới sớm |
| Penetration | Đọc key không tồn tại ở cả cache lẫn database | Validate, rate limit, cache giá trị rỗng, Bloom filter |
| Hot key, big key | Một key quá nhiều traffic, hoặc một value quá lớn | Local cache, nhân bản key, chia nhỏ value |
| Thrashing | Cache liên tục đẩy ra đúng dữ liệu sắp cần | Tăng dung lượng, đổi eviction policy, tìm luồng quét dữ liệu |
| Memory fragmentation | RAM bị chiếm nhiều hơn dữ liệu thật | Active defrag, tách loại dữ liệu khác kích thước |
| Connection churn | Mở và đóng kết nối liên tục | Connection pool, proxy |
Cache avalanche
Một lượng lớn key hết hạn hoặc biến mất cùng lúc, mọi request miss đổ xuống database.
- Nguyên nhân: nạp cùng lúc với cùng TTL (sau deploy, sau job nạp đêm); cache server sập, khởi động lại, hoặc vừa mới dựng (cache lạnh); traffic tăng vọt đúng lúc key hết hạn (flash sale).
- Xử lý:
- TTL cộng thêm phần ngẫu nhiên (60 phút + 0–5 phút) để key hết hạn rải rác.
- Cache sẵn sàng cao: replica, Sentinel hoặc Cluster.
- Nạp trước (pre-warming): chạy script nạp dữ liệu quan trọng trước khi mở traffic hoặc trước sự kiện lớn.
- Bảo vệ database ở phía app: circuit breaker, rate limit, giới hạn số query đồng thời.
Thundering herd (cache stampede)
Giống avalanche nhưng chỉ với một key nóng (kết quả xổ số lúc 18h30, trang chủ). Key hết hạn hoặc bị xoá, hàng nghìn request cùng miss trong lúc query đầu tiên chưa xong, và cùng chạy cùng một query.
Cách xử lý tuỳ mô hình cache:
- Inline cache (CDN, Nginx, Caffeine) xử lý ngay trong lớp cache:
- Trả dữ liệu cũ trong lúc làm mới: Nginx
proxy_cache_use_stale+proxy_cache_background_update; CaffeinerefreshAfterWrite. Người dùng thấy dữ liệu cũ một lúc ngắn; không cứu được trường hợp key bị đẩy ra hoặc cache lạnh. - Gom request (request coalescing): chỉ một request đại diện đi lấy dữ liệu, các request khác chờ kết quả đó. Nginx
proxy_cache_lock.
- Trả dữ liệu cũ trong lúc làm mới: Nginx
- Cache-aside (app tự đọc Redis rồi database):
- Gom request trong process (singleflight): khoá phải bao cả đoạn đọc cache → miss → query database → ghi cache, không chỉ đoạn đọc cache. Chỉ gom trong một process: 20 app server vẫn tạo 20 query. Node.js: lưu Promise đang chạy trong một
Maptheo key. - Lock phân tán: request đầu tiên lấy lock trên Redis (
SET lock:key token NX PX 3000) rồi đi query; các request khác chờ một chút rồi đọc lại cache. Lock phải có TTL, nếu không process giữ lock chết thì không ai lấy được nữa (Distributed lock). - Trả dữ liệu cũ, làm mới ngầm (stale-while-revalidate): lưu kèm thời điểm “nên làm mới” sớm hơn TTL thật; quá thời điểm đó thì trả giá trị cũ và để một request làm mới ngầm.
- Làm mới sớm theo xác suất (X-Fetch): mỗi lần đọc, request tự quyết định có làm mới sớm hay không theo công thức bên dưới.
- Gom request trong process (singleflight): khoá phải bao cả đoạn đọc cache → miss → query database → ghi cache, không chỉ đoạn đọc cache. Chỉ gom trong một process: 20 app server vẫn tạo 20 query. Node.js: lưu Promise đang chạy trong một
js
// X-Fetch (Vattani và cộng sự, VLDB 2015)
// delta: thời gian một lần nạp lại (ms), expiry: thời điểm key hết hạn
const shouldRefresh = (now, delta, expiry, beta = 1) =>
now - delta * beta * Math.log(Math.random()) >= expiry−log(rand) không bị chặn trên, nên khi còn Δt trước hạn, xác suất một request làm mới sớm là e^(−Δt / (β·δ)): rất nhỏ khi còn xa hạn, tăng dần khi gần hạn. Giữa hàng nghìn request, gần như chắc chắn có một request làm mới trước khi key hết hạn, nhưng hiếm khi nhiều request cùng làm. Ưu điểm: đơn giản, không cần lock; key không còn ai đọc thì không bị làm mới. Nhược điểm: cần ước lượng δ, và làm mới cả dữ liệu không đổi.
So sánh năm cách trên cùng tham số:
Chiến lược
Mô phỏng 3 giây traffic vào một key; key hết hạn ở giây 1,5.
1.000query xuống database / 15.000 request
1.000query chạy cùng lúc (cao nhất)
201 msp99 latency
0request nhận dữ liệu cũ
Biểu đồ: số query database đang chạy theo thời gian (trục dọc tự co giãn).
| Chiến lược | Query DB | Cùng lúc | p99 | Dữ liệu cũ | Đánh đổi |
|---|---|---|---|---|---|
| Không làm gì | 1.000 | 1.000 | 201 ms | 0 | Cả đàn đổ xuống database trong suốt thời gian query đầu tiên chưa xong. |
| Lock phân tán (SET NX PX) | 1 | 1 | 202 ms | 0 | Database an toàn; người chờ chậm thêm và Redis nhận thêm lệnh đọc lặp. |
| Singleflight trong từng server | 10 | 10 | 171 ms | 0 | Số query bằng số app server, không cần lock trên Redis. |
| Stale-while-revalidate | 1 | 1 | 1 ms | 1.000 | Nhanh nhất và database nhẹ nhất, nhưng người dùng thấy dữ liệu cũ trong lúc làm mới. |
| X-Fetch (hết hạn sớm theo xác suất) | 1 | 1 | 1 ms | 0 | Thường làm mới xong trước khi key hết hạn; không phối hợp nên đôi khi có vài query cùng lúc. |
Cache penetration
Đọc liên tục một key không có trong cache và cũng không có trong database: user_id = -1, UUID ngẫu nhiên. Cache không bao giờ hit, mọi request xuống database; kẻ tấn công dùng cách này để làm quá tải database.
- Validate input và rate limit trước tiên. Kiểm tra định dạng id, giới hạn request theo IP hoặc user (Rate limiter).
- Cache giá trị rỗng với TTL ngắn (vài chục giây). Hiệu quả khi cùng một key không tồn tại bị hỏi lặp lại. Vô dụng trước key ngẫu nhiên: mỗi key chỉ xuất hiện một lần nên không bao giờ hit, mà còn đẩy dữ liệu thật ra khỏi cache.
- Bloom filter: cấu trúc dữ liệu xác suất rất tiết kiệm bộ nhớ, trả lời “chắc chắn không có” hoặc “có thể có” (dương tính giả). Chặn ngay các id chắc chắn không tồn tại. Nhược điểm: không xoá được phần tử (cần Counting Bloom filter hoặc Cuckoo filter); tỷ lệ dương tính giả tăng khi đầy dần; phải thêm phần tử vào filter mỗi khi tạo bản ghi mới. Redis có module RedisBloom.
Hot key và big key
Thundering herd là chuyện lúc key hết hạn. Hot key là một key nhận quá nhiều traffic làm nghẽn đúng một node, ngay cả khi luôn hit. Big key là value quá lớn (vài MB, list hàng triệu phần tử).
- Hot key: cache cục bộ trong từng app server (L1, TTL ngắn), nhân bản key thành
k#1 … k#nrồi đọc ngẫu nhiên một bản, đọc từ replica của Redis. - Big key: Redis chạy lệnh trên một luồng nên đọc hoặc xoá một key lớn chặn mọi client khác. Chia nhỏ value, xoá bằng
UNLINK(xoá ngầm) thay vìDEL. Tìm bằngredis-cli --bigkeys,--memkeys; hot key bằngredis-cli --hotkeys(cần policy LFU).
Thrashing và eviction policy
Thrashing: dữ liệu liên tục được nạp vào và đẩy ra, đúng thứ vừa bị đẩy ra lại được cần ngay. Triệu chứng: hit rate giảm mạnh, eviction rate tăng dựng đứng, CPU và I/O của database tăng vọt.
- Nguyên nhân: dung lượng cache nhỏ hơn lượng dữ liệu nóng; eviction policy không hợp với kiểu truy cập; nhiều ứng dụng dùng chung một cache nhỏ và đẩy dữ liệu của nhau; một luồng logic quét qua toàn bộ dữ liệu (job báo cáo, export).
- Xử lý: tăng dung lượng; đổi policy; tách cache cho ứng dụng khác nhau; tìm và sửa luồng quét.
Ba policy chạy song song trên cùng một chuỗi truy cập:
ABACABDABCABs1s2s3s4s5ABCABADAB
LRU–
đẩy key lâu nhất chưa dùng–trống
–trống
–trống
–trống
LFU–
đẩy key ít dùng nhất–trống
–trống
–trống
–trống
FIFO–
đẩy key vào sớm nhất–trống
–trống
–trống
–trống
Bấm “Bước tiếp” hoặc “Tự chạy”. Ô viền xanh lá là hit, ô viền xanh dương là key vừa nạp, chữ đỏ là key sẽ bị đẩy ra tiếp theo.
- LRU dễ bị một lần quét đẩy hết key nóng, vì key vừa quét được coi là “mới dùng”. InnoDB buffer pool giải quyết đúng vấn đề này bằng LRU chia vùng young/old (MySQL chương 1).
- LFU giữ được key nóng, nhưng key từng rất nóng rồi nguội có thể nằm lì; Redis LFU có hệ số suy giảm theo thời gian để xử lý.
- W-TinyLFU (thư viện Caffeine) kết hợp một cửa sổ LRU nhỏ với bộ lọc tần suất, chống được cả quét lẫn key nguội.
- Redis mặc định không đẩy key nào (
noeviction): đầy bộ nhớ thì lệnh ghi lỗi. Phải tự đặtallkeys-lruhoặcallkeys-lfu(Redis). Memcached mặc định dùng một biến thể của LRU.
Memory fragmentation
RAM bị chia thành nhiều mảnh trống rời rạc: tổng bộ nhớ trống còn nhiều nhưng không dùng hiệu quả được, process chiếm RAM nhiều hơn hẳn lượng dữ liệu thật.
- Phân mảnh ngoài: tạo, sửa, xoá liên tục các key kích thước khác nhau để lại các khoảng trống xen kẽ. Bộ cấp phát bộ nhớ (jemalloc trong Redis) không trả lại được các mảnh nhỏ cho hệ điều hành.
- Phân mảnh trong: bộ cấp phát chia bộ nhớ thành các khối kích thước cố định (đây là việc của bộ cấp phát như malloc, jemalloc, slab của Memcached; hệ điều hành cấp theo page 4 KB). Value 97 byte vào khối 120 byte thì phí 23 byte.
- Đo:
mem_fragmentation_ratio= bộ nhớ hệ điều hành cấp cho process / bộ nhớ dữ liệu dùng thật. Trên 1,5 là phân mảnh nặng; dưới 1 là đang dùng swap (rất chậm). - Xử lý (Redis): bật active defrag (
CONFIG SET activedefrag yes, Redis tự dồn bộ nhớ khi chạy với jemalloc đi kèm); bản cũ thì lưu RDB/AOF rồi khởi động lại; tách dữ liệu khác hẳn kích thước (session vài chục byte vs trang HTML vài MB) sang cụm riêng.
Connection churn
Client mở kết nối TCP mới, gửi vài lệnh, đóng, rồi lặp lại với tần suất cao.
- Tốn CPU cho bắt tay TCP (3 bước) và đóng kết nối (4 bước); server dành thời gian cho system call thay vì chạy GET/SET.
- Cạn port ở phía client. Bên chủ động đóng kết nối (thường là app server) giữ socket ở trạng thái
TIME_WAIT(60 giây trên Linux). App server có khoảng 28 nghìn port tạm (ephemeral) theo mặc định Linux; mở đóng hàng nghìn kết nối mỗi giây là cạn. Cache server chỉ dùng một port lắng nghe nên không bị cạn port. - Xử lý: connection pool ở app (ioredis giữ kết nối lâu dài; không tạo client mới cho mỗi request); proxy ở giữa (Twemproxy, Envoy) giữ kết nối tới cache.
- Phát hiện: nhìn tốc độ tạo kết nối mới, không nhìn số kết nối đang mở (khi churn, mở và đóng cân bằng nên con số này đứng yên) (Giám sát).
Ghi nhớ
- Avalanche: TTL jitter, cache sẵn sàng cao, nạp trước.
- Stampede: gom request (phải bao cả đoạn xuống database; singleflight chỉ trong một process), lock có TTL, trả dữ liệu cũ, X-Fetch.
- Penetration: validate và rate limit trước; cache giá trị rỗng vô dụng với key ngẫu nhiên; Bloom filter.
- LRU bị quét đẩy hết key nóng; LFU, W-TinyLFU, LRU chia vùng chống được.
- Fragmentation đo bằng tỷ lệ RSS / dữ liệu; connection churn làm cạn port ở client.