Bỏ qua để đến nội dung
Knowledge Hub

Cache-aside pattern Cơ bản

Thời gian đọc ước tính: 7 phút

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.

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.

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
Cache-aside: đọc cache, miss thì đọc DB rồi ghi lại cache
Cache-aside: đọc cache, miss thì đọc DB rồi ghi lại cacheBước 1/4: Ứng dụng đọc key user:42 từ Redis trước khi query database.AppRedisPostgreSQLGET user:42
App → RedisGET user:42
Bước 1/4

Ứ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.

Stale set: R1 đọc An, W commit Anh và xoá key, R1 ghi An vào cache
Stale set: R1 đọc An, W commit Anh và xoá key, R1 ghi An vào cacheBước 1/5: R1 gọi GET, cache miss, và bắt đầu nạp lại từ PostgreSQL.thời gianGET → missReader R1đọc user 42Redisuser:42:profileWriter Wđổi tên thành Anh
Bước 1/5

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.

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 DEL khi 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.

Ư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
  1. 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.
  2. TTL luôn có jitter; key nạp hàng loạt không được hết hạn cùng giây.
  3. Ghi DB trước, DEL sau; xử lý trường hợp DEL thất bại (retry, hoặc đẩy qua outbox).
  4. Có negative cache với TTL ngắn cho key không tồn tại.
  5. 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.
  6. Hot key: gộp request nạp lại (stampede).
  7. Metric: hit rate, latency nhánh miss, số query DB theo fingerprint, evicted_keys.
  8. Luồng cần đúng tuyệt đối đọc thẳng từ nguồn chính.

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?