Cache-aside pattern Cơ bản
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”API hồ sơ người dùng query PostgreSQL ở mỗi request, dù cùng một hồ sơ được đọc hàng nghìn lần và hiếm khi đổi. Khi đo thấy các query lặp lại chiếm phần lớn tải database, cache giảm số lần chạm nguồn dữ liệu. Cache không thay việc sửa query chậm: kiểm tra index và query plan trước.
Cache-aside (lazy loading) là pattern mà ứng dụng, không phải cache, giữ toàn bộ logic: khi nào đọc cache, khi nào đọc DB, khi nào ghi lại hoặc xoá key. Redis chỉ là kho key-value.
Bốn chiến lược cache, một bảng
Phần tiêu đề “Bốn chiến lược cache, một bảng”| Cache-aside | Read-through | Write-through | Write-behind | |
|---|---|---|---|---|
| Ai nạp cache khi miss | Ứng dụng | Lớp cache | Lớp cache (khi ghi) | Lớp cache (khi ghi) |
| Khi ghi | Ghi DB, xoá key | Giống cache-aside | Ghi cache và DB đồng bộ | Ghi cache, DB cập nhật sau |
| Dữ liệu cũ | Có (race, TTL) | Có | Ít hơn | Cache mới hơn DB |
| Rủi ro mất dữ liệu | Không | Không | Không | Có, nếu cache chết trước khi flush |
| Độ phức tạp | Thấp, chỉ cần Redis | Cần thư viện/proxy cache | Cao hơn | Cao nhất |
Cache-aside là mặc định khi dùng Redis thuần: không cần thành phần nào ngoài client. Ba chiến lược còn lại cần một lớp cache biết cách nói chuyện với database.
Khi nào dùng, khi nào không
Phần tiêu đề “Khi nào dùng, khi nào không”| Dùng khi | Không dùng khi |
|---|---|
| Đọc nhiều hơn ghi nhiều lần | Ghi nhiều, dữ liệu đổi gần như mỗi lần đọc |
| Nghiệp vụ chấp nhận trễ cập nhật bằng TTL | Cần nhất quán chặt (số dư, quyền truy cập) |
| Mất cache chỉ làm chậm, DB vẫn chịu nổi | DB không chịu nổi khi cache trống |
Cách hoạt động
Phần tiêu đề “Cách hoạt động”GET user:42Ứng dụng đọc key user:42 từ Redis trước khi query database.
Theo một key user:42:profile từ lúc chưa có dữ liệu (TTL 300 giây):
| Thời điểm | Redis | PostgreSQL | Ứng dụng làm gì |
|---|---|---|---|
| Request 1 | Chưa có key | An |
Đọc PostgreSQL, SET An với TTL, trả An |
| Request 2 | An |
An |
Trả An từ Redis, không chạm DB |
| User đổi tên | An (tạm thời) |
Commit Anh |
DEL key sau khi commit |
| Request 3 | Chưa có key | Anh |
Nạp Anh vào cache rồi trả |
Quy tắc cần nhớ: đọc thì thử bản sao trước; ghi thì sửa nguồn chính trước rồi xoá bản sao. Xoá key trước khi commit là sai: một request chen vào giữa sẽ đọc giá trị cũ từ DB và nạp lại chính nó.
Code tối thiểu (minh hoạ, chưa chạy trong repo):
async function getUser(id: number) { const key = `user:${id}:profile`; const hit = await redis.get(key); if (hit !== null) return hit === NULL_MARK ? null : JSON.parse(hit);
const row = await db.findUser(id); const ttl = row ? 300 + Math.floor(Math.random() * 60) : 30; // jitter; negative cache ngắn await redis.set(key, row ? JSON.stringify(row) : NULL_MARK, 'EX', ttl); return row;}
async function updateUser(id: number, patch: Partial<User>) { await db.updateUser(id, patch); // 1. commit nguồn chính await redis.del(`user:${id}:profile`); // 2. rồi mới xoá bản sao}Race: cache giữ bản cũ dù không có lỗi nào
Phần tiêu đề “Race: cache giữ bản cũ dù không có lỗi nào”Thứ tự “ghi DB rồi xoá key” vẫn có một kẽ hở. Một reader đọc DB trước commit nhưng SET sau DEL sẽ đặt lại bản cũ vào cache.
R1 gọi GET, cache miss, và bắt đầu nạp lại từ PostgreSQL.
Không thao tác nào thất bại, và cache giữ An cho tới khi TTL hết. Cách giảm thiểu, từ rẻ đến đắt:
| Cách | Hiệu quả | Chi phí |
|---|---|---|
| TTL ngắn có jitter | Giới hạn thời gian tồn tại của bản cũ | Thêm miss và tải DB |
| Xoá lại sau một khoảng ngắn (delayed double delete) | Bắt được hầu hết race, không tuyệt đối | Cần job trễ; khoảng chờ phải lớn hơn thời gian reader đọc DB |
Lease/version: chỉ SET nếu key chưa bị đổi từ lúc miss |
Chặn hẳn stale set | Phức tạp; Facebook mô tả cơ chế lease trong bài Scaling Memcache |
| Đọc nguồn chính cho luồng quan trọng | Loại bỏ vấn đề | Không giảm tải DB cho luồng đó |
Chọn mặc định: TTL ngắn có jitter, cộng đọc từ DB cho luồng cần đúng tuyệt đối.
Negative caching và TTL
Phần tiêu đề “Negative caching và TTL”Request cho key không tồn tại (user 999999) miss mãi, và mỗi lần đều chạm DB. Kẻ tấn công hoặc client lỗi có thể dùng điều này để đẩy tải xuống DB. Cache cả kết quả “không có” (như NULL_MARK ở trên) với TTL ngắn, ví dụ 30 giây, và xoá nó khi bản ghi được tạo.
Chọn TTL từ yêu cầu nghiệp vụ, không có con số chung:
- Danh mục tỉnh thành gần như không đổi: TTL dài (giờ).
- Hồ sơ người dùng: vài phút, kèm
DELkhi ghi. - Số dư, quyền truy cập: không cache, hoặc cache cực ngắn và xoá khi ghi.
Thêm jitter vào TTL để các key nạp cùng lúc không hết hạn cùng lúc (xem cache stampede). Key bị mất sớm hơn TTL vẫn có thể xảy ra vì eviction; xem expiration và eviction.
Thiết kế key theo tốc độ thay đổi của dữ liệu: hồ sơ (đổi chậm) và vị trí hiện tại (đổi mỗi giây) phải nằm ở key khác nhau. Nếu response phụ thuộc quyền hoặc ngôn ngữ, đưa chiều đó vào key hoặc không cache.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
- Giảm tải DB đáng kể cho dữ liệu đọc nhiều, ghi ít
- Chỉ cache dữ liệu thực sự được đọc (lazy)
- Mất một key cache không mất dữ liệu; DB vẫn là nguồn chính
Nhược điểm
- Lần đọc đầu sau mỗi miss luôn chậm hơn
- Có khoảng trống giữa ghi DB và xoá cache, và race stale set
- Thêm một điểm lỗi (Redis) và một loại bug mới: cache lệch DB
Checklist production
Phần tiêu đề “Checklist production”- Chốt “dữ liệu được phép cũ bao lâu” cho từng loại key, rồi mới chọn TTL.
- TTL luôn có jitter; key nạp hàng loạt không được hết hạn cùng giây.
- Ghi DB trước,
DELsau; xử lý trường hợpDELthất bại (retry, hoặc đẩy qua outbox). - Có negative cache với TTL ngắn cho key không tồn tại.
- Khi Redis lỗi: timeout ngắn, circuit breaker, giới hạn concurrency xuống DB. Fallback không có nghĩa DB chịu được toàn bộ traffic.
- Hot key: gộp request nạp lại (stampede).
- Metric: hit rate, latency nhánh miss, số query DB theo fingerprint,
evicted_keys. - Luồng cần đúng tuyệt đối đọc thẳng từ nguồn chính.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao xoá key trước commit là sai? (2) Vẽ lại trình tự khiến cache giữ An sau khi DB đã là Anh. (3) Khi nào write-behind nguy hiểm hơn cache-aside?