🔁 Cache-aside: luồng đọc ghi kèm ví dụ

By Better Wise

Bài trước ta đã chốt hai điều: cache là bản sao dữ liệu đặt ở nơi đọc nhanh hơn nguồn gốc, và mỗi lần không tìm thấy key trong cache là một cache miss. Câu hỏi còn lại là: ai ghi vào bản sao đó, và ghi theo thứ tự nào? Cache-aside là câu trả lời phổ biến nhất, và cũng là pattern bạn sẽ gặp nhiều nhất khi đọc code backend thật.

Sau bài này bạn có thể:

  • Vẽ lại luồng đọc của cache-aside đúng 5 bước.
  • Vẽ lại luồng ghi: cập nhật nguồn gốc rồi xóa key.
  • Chỉ ra 3 kiểu sai thứ tự dẫn tới dữ liệu cũ.
  • Trả lời câu hỏi "dữ liệu này cũ bao lâu thì chấp nhận được" để chọn TTL.

Cache-aside qua chiếc tủ lạnh

Tủ lạnh là cache, siêu thị là database. Đói thì bạn mở tủ lạnh trước. Không có gì thì mới đi siêu thị, mua về và cất vào tủ. Điểm mấu chốt: siêu thị không tự mang đồ bỏ vào tủ lạnh của bạn. Trong cache-aside, ứng dụng là người duy nhất nói chuyện với cả cache và database. Cache không tự pull dữ liệu, không tự biết dữ liệu nào vừa thay đổi. Mọi bước đọc ghi đều nằm trong code của bạn.

Vì thế pattern này còn có tên khác là lazy loading: chỉ nạp vào cache khi có người hỏi.

Luồng đọc: mở tủ lạnh trước

flowchart TD
    A["📥 Request"] --> B["🔍 Đọc key trong cache"]
    B --> C{"Có trong cache?"}
    C -->|Hit| D["⚡ Trả luôn từ cache"]
    C -->|Miss| E["🗄️ Đọc nguồn gốc (DB)"]
    E --> F["💾 Ghi lại cache kèm TTL"]
    F --> G["Trả dữ liệu cho client"]
    D --> G

Năm bước, đúng thứ tự này:

  1. Đọc key trong cache.
  2. Nếu hit: trả dữ liệu từ cache, không chạm database.
  3. Nếu miss: đọc từ nguồn gốc.
  4. Ghi kết quả vừa đọc vào cache, luôn kèm TTL.
  5. Trả dữ liệu cho client.

Sign in to view the full content of this lesson. Sign in

async function getUser(userId) {
  const key = `user:${userId}`;

  const cached = await redis.get(key);          // 1. đọc cache trước
  if (cached) return JSON.parse(cached);        // 2. hit: trả luôn

  const user = await db.users.findById(userId); // 3. miss: đọc nguồn
  await redis.set(key, JSON.stringify(user), { EX: 300 }); // 4. ghi lại, TTL 5 phút
  return user;                                  // 5. trả client
}

Ví dụ có số: trang profile user nhận 400 request/giây, mỗi query database mất 40ms. Request đầu tiên miss: 40ms query + 2ms ghi cache. 399 request sau đó hit: khoảng 2ms. Database chỉ còn nhận 1 query thay vì 400 mỗi giây, miễn là TTL còn sống.

Lưu ý một chi tiết dễ bỏ qua: nếu database trả về null (không có user đó), bạn vẫn nên cache giá trị "không tồn tại" với TTL ngắn, nếu không mỗi request cho id sai lại đâm thẳng vào database.

Luồng ghi: sửa sổ trước, dọn tủ sau

Khi dữ liệu thay đổi, cache-aside không ghi đè giá trị mới vào cache. Nó xóa key, để lần đọc sau tự nạp lại. Thứ tự đúng là: cập nhật nguồn gốc trước, rồi xóa key trong cache.

async function updateUserEmail(userId, email) {
  const user = await db.users.update(userId, { email }); // 1. ghi nguồn trước
  await redis.del(`user:${userId}`);                     // 2. xóa key trong cache
  return user;
}

Vì sao xóa thay vì ghi đè? Vì hai request ghi cùng lúc có thể chen nhau: request A ghi DB xong, request B ghi DB xong, rồi B ghi cache, rồi A ghi cache. Kết quả là cache giữ giá trị của A trong khi DB giữ giá trị của B. Xóa key thì không có cửa cho kiểu lệch đó, và lần đọc kế tiếp luôn đọc từ nguồn mới nhất.

Warning: Đừng bao giờ ghi vào cache trước rồi mới ghi database. Nếu bước ghi database thất bại, cache sẽ giữ một bản ghi mà nguồn gốc không hề có.

Thứ tự sai thì sao?

Cách làm Kết quả
Ghi DB, rồi xóa cache Đúng. Lần đọc sau miss và nạp dữ liệu mới
Ghi DB, quên xóa cache Dữ liệu cũ sống đến khi TTL hết hạn
Ghi cache, rồi ghi DB DB lỗi thì cache giữ dữ liệu không tồn tại
Xóa cache, rồi mới ghi DB Request đọc chen giữa sẽ ghi lại dữ liệu cũ vào cache
- await redis.del(key);                  // xóa cache trước: sai
- await db.users.update(userId, patch);
+ await db.users.update(userId, patch);  // ghi nguồn trước: đúng
+ await redis.del(key);                  // rồi xóa cache

Ví dụ một cuộc đua thật. User đổi email từ cũ sang mới, và đúng lúc đó có người mở trang profile:

sequenceDiagram
    participant R1 as Request A (đọc)
    participant Cache as Cache
    participant DB as Database
    participant W as Request B (ghi)
    W->>Cache: del key
    R1->>Cache: get key
    Cache-->>R1: miss
    R1->>DB: đọc dữ liệu
    DB-->>R1: email cũ
    R1->>Cache: set email cũ
    W->>DB: update email mới
    Note over Cache,DB: Cache giữ email cũ đến hết TTL

Database đã có email mới, nhưng cache vẫn trả email cũ cho mọi người. Đây là lý do TTL không phải thứ trang trí: nó là lưới an toàn cuối cùng khi thứ tự bị sai hoặc khi một cuộc đua lọt qua khe.

Tự kiểm tra 30 giây

Điền vào chỗ trống để nhớ thứ tự:

  • Bước đầu tiên của luồng đọc là _____.
  • Khi cache miss, ứng dụng đọc từ _____ rồi ghi lại vào cache.
  • Khi cập nhật, thứ tự đúng là ghi nguồn gốc trước, sau đó _____.
  • Nếu quên xóa cache, dữ liệu cũ chỉ biến mất khi _____.

Bốn thói quen khi viết cache-aside

  • Luôn set TTL, kể cả khi bạn đã có invalidation chủ động. TTL là thứ duy nhất cứu bạn khi ai đó quên một chỗ del.
  • Hỏi trước khi code: dữ liệu này cũ 5 phút có sao không? Giá sản phẩm thì không chấp nhận được, số lượt xem thì thoải mái. Câu trả lời quyết định TTL, không phải ngược lại.
  • Cùng một format key ở chỗ đọc và chỗ ghi. Đọc user:42 mà xóa users:42 thì cache không bao giờ được dọn.
  • Khe hở nhỏ vẫn còn: một request đọc lấy dữ liệu cũ trước khi update, rồi ghi lại cache sau khi bạn đã xóa. Cách xử lý triệt để nằm ở các module sau, còn ở mức nhập môn thì TTL ngắn là đủ để thiệt hại có giới hạn.

Chốt lại

  • Cache-aside để ứng dụng tự lo cả hai phía: đọc cache trước, miss thì đọc nguồn, ghi lại kèm TTL.
  • Khi ghi: cập nhật nguồn gốc trước, xóa key sau. Không ghi đè giá trị mới vào cache.
  • Mọi sai thứ tự đều dẫn về cùng một triệu chứng: dữ liệu cũ, và thời gian sống của nó bị chặn trên bởi TTL.
  • Bài tiếp theo là checkpoint của module: bạn sẽ tự vẽ lại hai luồng này và chỉ ra lỗi thứ tự trong vài đoạn code ngắn.

Bộ thẻ ôn tập

[
  { "front": "Cache-aside là gì?", "back": "Pattern trong đó ứng dụng tự đọc và ghi cache, cache không tự nói chuyện với database." },
  { "front": "Cache-aside còn được gọi là gì?", "back": "Lazy loading, vì chỉ nạp dữ liệu vào cache khi có người hỏi." },
  { "front": "Bước đầu tiên của luồng đọc trong cache-aside?", "back": "Đọc key trong cache trước khi chạm database." },
  { "front": "Khi cache hit thì xử lý thế nào?", "back": "Trả dữ liệu từ cache luôn, không gọi database." },
  { "front": "Khi cache miss thì làm gì?", "back": "Đọc từ nguồn gốc, ghi kết quả vào cache kèm TTL, rồi trả về client." },
  { "front": "Thứ tự đúng khi cập nhật dữ liệu trong cache-aside?", "back": "Cập nhật nguồn gốc trước, sau đó xóa key trong cache." },
  { "front": "Vì sao xóa key thay vì ghi đè giá trị mới vào cache?", "back": "Xóa tránh được việc hai request ghi chen nhau làm cache giữ giá trị cũ." },
  { "front": "Nếu xóa cache trước rồi mới ghi database thì sao?", "back": "Request đọc chen giữa sẽ miss, đọc dữ liệu cũ và ghi lại dữ liệu cũ vào cache." },
  { "front": "Hậu quả của việc cập nhật database mà quên xóa cache?", "back": "Dữ liệu cũ tồn tại cho đến khi TTL hết hạn." },
  { "front": "Nếu ghi cache trước rồi ghi database và database lỗi?", "back": "Cache giữ một bản ghi mà nguồn gốc hoàn toàn không có." },
  { "front": "Vì sao luôn phải set TTL khi ghi cache?", "back": "Để dữ liệu cũ tự hết hạn, giới hạn thiệt hại khi thứ tự bị sai." },
  { "front"front": "Trong cache-aside, ai là người đọc và ghi cache?", "back": "Chính code ứng dụng của bạn, cache không tự nói chuyện với database." },
  { "front": "Cache-aside có tự đồng bộ khi dữ liệu nguồn thay đổi không?", "back": "Không. Ứng dụng phải chủ động xóa key sau khi ghi nguồn gốc." },
  { "front": "Vì sao cache-aside hợp với dữ liệu đọc nhiều ghi ít?", "back": "Vì chỉ nạp dữ liệu khi có người hỏi, không tốn công làm nóng cache trước." },
  { "front": "Câu hỏi nên trả lời trước khi chọn TTL cho một key?", "back": "Dữ liệu này cũ bao lâu thì vẫn chấp nhận được với người dùng?" }
]

Tự kiểm tra nhanh bằng cloze

Điền phần còn thiếu, che tay lên đáp án rồi đọc to cả câu:

  • Trong cache-aside, ứng dụng đọc _____ trước, chỉ khi miss mới chạm _____.
  • Khi cập nhật dữ liệu, thứ tự đúng là ghi nguồn gốc trước rồi _____.
  • Mỗi lần ghi vào cache đều phải kèm _____ để dữ liệu cũ tự hết hạn, kể cả khi code xóa cache đã đúng.

Ba dòng để nhớ khi viết code cache-aside

  • Đọc: cache trước, miss thì đọc nguồn gốc, ghi lại cache kèm TTL, rồi trả về client. Không có bước nào được đảo.
  • Ghi: nguồn gốc trước, xóa key sau. Xóa, không ghi đè. Và luôn đặt TTL làm lưới an toàn.
  • Tự hỏi: "Nếu bước này chạy sai thứ tự, người dùng sẽ thấy dữ liệu cũ bao lâu?" Nếu câu trả lời là "mãi mãi", đó là chỗ cần sửa trước khi deploy.

Luồng bạn vừa viết lại là nền cho mọi thứ phía sau: bài tiếp theo là Checkpoint: Nền tảng cache, nơi bạn sẽ tự dựng lại luồng đọc ghi và chỉ ra lỗi thứ tự trong vài đoạn code mẫu ngắn.