Shop chạy khuyến mãi, lượng khách tăng gấp mấy lần, và trang danh sách sản phẩm bắt đầu tải chậm. Bạn xem lại thì thấy mỗi khách vào đều làm database chạy cùng một câu truy vấn nặng, lặp đi lặp lại, cho ra cùng một kết quả. Database là thứ gục đầu tiên.
Cách chữa quen thuộc là ghi nhớ tạm kết quả để lần sau trả ngay, không hỏi database nữa. Thứ ghi nhớ đó gọi là cache, và Redis là nơi rất nhiều web dùng để chứa nó. Ý tưởng dễ, nhưng làm cho đúng thì có mấy chỗ hay sai, và bài này đi qua từng chỗ.

Hình dung bằng cuốn sổ tay
Hãy nghĩ bạn là nhân viên tra cứu giá cho khách. Kho hàng (database) ở tận cuối đường. Mỗi lần khách hỏi, bạn lại chạy ra kho, mất công. Thay vào đó, bạn giữ một cuốn sổ tay trên bàn. Khách hỏi thì giở sổ trước. Có rồi thì trả lời luôn. Chưa có thì chạy ra kho, ghi kết quả vào sổ, rồi trả lời.
Đó chính là mẫu có tên cache-aside ("cache đứng bên cạnh"): ứng dụng tự quản cuốn sổ, kho không biết gì về sổ. Mỗi dòng trong sổ có hạn dùng (TTL, time to live), hết hạn thì coi như chưa có. Khi dữ liệu thật thay đổi, bạn cập nhật kho trước, rồi gạch dòng tương ứng trong sổ. Mình khuyên gạch bỏ chứ không sửa dòng trong sổ, vì hai lần cập nhật cùng lúc có thể để sổ lệch với kho.
Ba chỗ hay sai
Hết hạn cùng lúc. Nếu hàng nghìn dòng trong sổ cùng được ghi lúc 9 giờ với hạn một tiếng, đúng 10 giờ cả đống cùng hết, và khách ào cả vào kho. Cách đỡ: thêm một chút ngẫu nhiên vào hạn dùng của mỗi dòng (gọi là jitter).
Xoá nhầm hoặc sót. Một trang danh sách sản phẩm có thể nằm trong hàng chục dòng của sổ (mỗi trang, mỗi bộ lọc, mỗi ngôn ngữ). Tìm từng dòng để gạch rất dễ sót. Cách gọn là ghi số phiên bản vào tên dòng. Khi dữ liệu đổi, tăng số phiên bản lên, và mọi dòng cũ tự trở thành "mồ côi" rồi hết hạn dần.
Cả đám đông cùng chạy ra kho. Một dòng nổi tiếng vừa hết hạn, 500 khách cùng hỏi đúng lúc, và 500 lượt chạy ra kho cho cùng một câu trả lời. Đây gọi là cache stampede (đàn gia súc giẫm đạp). Cách chữa là chỉ cho một người ra kho, những người còn lại chờ ghi chép của người đó (dùng một "thẻ khoá" trong Redis), hoặc cho phép trả lời bằng bản cũ trong lúc một người đi lấy bản mới.

Làm thế nào
- Chọn truy vấn chậm và lặp lại nhiều nhất, đừng cache mọi thứ.
- Đặt hạn dùng theo câu hỏi "người dùng chấp nhận số liệu cũ bao lâu": vài chục giây cho số đếm, vài phút cho danh sách sản phẩm, vài giờ cho dữ liệu hiếm đổi. Cộng thêm chút ngẫu nhiên.
- Mọi tên dòng bắt đầu bằng một tiền tố riêng của ứng dụng, ví dụ
shop.example.com:. - Khi ghi dữ liệu: ghi database trước, rồi mới xoá hoặc tăng phiên bản của cache.
- Với dòng được hỏi nhiều, thêm chống giẫm đạp.
- Nếu Redis có sự cố, API vẫn phải trả lời được, chỉ chậm hơn.
Điểm 3 đặc biệt quan trọng khi nhiều site dùng chung một Redis, như trên một VPS gánh vài site. Hai site cùng dùng tên dòng option:home sẽ đọc và ghi đè dữ liệu của nhau. Mình thử: hai client cùng tiền tố, client A ghi A, client B ghi B, rồi A đọc ra B. Khác tiền tố thì mỗi bên đọc đúng dữ liệu của mình. Và không bao giờ chạy FLUSHALL trên Redis dùng chung: nó xoá cache của tất cả các site. Muốn dọn một site, quét theo tiền tố bằng SCAN rồi UNLINK. Mình thử xoá 6 dòng của site A, dòng của site B vẫn còn nguyên.
Cho bạn nào viết code
Mình chạy lại các đoạn dưới đây trên Redis 8 thật, Node 20 và ioredis. Phần thử dùng tiền tố riêng và đã dọn sạch sau khi xong.
Client Redis tạo một bản dùng chung (mẫu globalThis trong bài Dùng chung một kết nối cho cả ứng dụng: Singleton đúng cách), cấu hình keyPrefix để mọi khoá tự có tiền tố của ứng dụng. Hàm cache-aside kèm jitter:
const jitter = (ttl: number, ratio = 0.1) => Math.round(ttl + Math.random() * ttl * ratio);
export async function cacheAside<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T> {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit) as T;
const value = await loader();
await redis.set(key, JSON.stringify(value), "EX", jitter(ttlSec));
return value;
}
Với loader giả lập truy vấn chậm 200 ms, lần đầu mất khoảng 202 ms, lần sau đọc từ cache chỉ khoảng 0,3 ms và loader chỉ được gọi 1 lần. Hạn 60 giây còn lại 64 giây sau khi cộng jitter. Con số này đo trên máy mình với loader giả, đừng coi là số của truy vấn thật.
Khoá theo phiên bản:
export async function versionedKey(group: string, ...parts: (string | number)[]): Promise<string> {
const v = (await redis.get(`ver:${group}`)) ?? "0"; // mặc định "0", không phải "1"!
return `${group}:v${v}:${parts.join(":")}`;
}
export const bumpVersion = (group: string) => redis.incr(`ver:${group}`);
Chỗ "0" đáng chú ý. Bản đầu mình viết ?? "1", nhưng INCR trên khoá chưa tồn tại cho ra 1, nên sau khi tăng, tên dòng vẫn là v1 y như trước và cache không bao giờ được làm mới. Đổi thành "0" thì tên dòng chuyển từ products:v0:list:page1 sang products:v1:list:page1, còn dòng cũ nằm đó cho tới khi hết hạn. Vì thế mọi dòng có phiên bản đều phải có hạn dùng.
Chống giẫm đạp bằng khoá phân tán:
import { randomUUID } from "node:crypto";
const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function cacheLocked<T>(key: string, ttlSec: number, loader: () => Promise<T>, lockMs = 10_000, waitMs = 3_000): Promise<T> {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit) as T;
const lockKey = `lock:${key}`;
const token = randomUUID();
if ((await redis.set(lockKey, token, "PX", lockMs, "NX")) === "OK") {
try {
const value = await loader();
await redis.set(key, JSON.stringify(value), "EX", jitter(ttlSec));
return value;
} finally {
await redis.eval(RELEASE, 1, lockKey, token); // chỉ nhả khoá của chính mình
}
}
const deadline = Date.now() + waitMs; // thua cuộc: chờ người thắng điền cache
while (Date.now() < deadline) {
await sleep(25);
const v = await redis.get(key);
if (v !== null) return JSON.parse(v) as T;
}
return loader(); // chờ quá lâu thì tự làm
}
SET ... NX PX nghĩa là "chỉ đặt nếu chưa ai đặt, kèm hạn dùng", làm trọn trong một bước nên đúng một request giành được khoá. Khoá có hạn để lỡ tiến trình chết thì không kẹt mãi. Khi nhả khoá, đoạn Lua kiểm tra token để không xoá nhầm khoá của người khác (nhả bằng token sai trả 0, token đúng trả 1).
Kết quả: 50 request đồng thời vào một khoá nguội, cacheAside gọi loader 50 lần, cacheLocked gọi đúng 1 lần. Mình thử cùng ý tưởng trên PHP với phpredis: 8 tiến trình chạy đồng thời, bản không khoá chạm database 8 lần, bản có khoá chỉ 1 lần. Riêng phpredis có một cạm bẫy: khi đã đặt tiền tố bằng OPT_PREFIX, thư viện tự thêm tiền tố vào khoá truyền cho eval. Nếu bạn thêm tay nữa thì khoá bị gắn hai lần và không bao giờ được nhả.
Còn một cách khác là trả bản cũ trong lúc làm mới ngầm (stale-while-revalidate). Với hạn "tươi" 1 giây, lần nguội mất 200 ms, lần tươi 0,2 ms, và khi đã hết tươi vẫn trả bản cũ sau 0,5 ms, rồi lần gọi kế đã thấy bản mới. Hợp cho trang danh sách và thống kê, nơi cũ vài giây không sao.
Lỗi hay gặp
Lỗi đầu tiên là để cache chết kéo cả API chết theo. Cache hỏng chỉ nên làm API chậm đi, không làm nó hỏng. Bọc việc đọc ghi cache trong try/catch và coi lỗi như hụt cache. Mình thử trỏ client tới một cổng không có Redis, hàm bọc vẫn trả đúng dữ liệu từ loader.
Lỗi thứ hai: hạn của khoá ngắn hơn thời gian chạy của loader. Khoá hết hạn trong lúc người đầu còn đang chạy, người thứ hai bước vào, và bạn quay lại cảnh cả đám đông ra kho. Đặt hạn khoá lớn hơn thời gian chạy tệ nhất.
Lỗi thứ ba là thứ tự xoá: xoá cache trước rồi mới ghi database. Một request chen giữa sẽ nạp lại dữ liệu cũ vào cache. Thứ tự đúng là ghi database trước.
Nên làm gì tiếp theo
Chọn một truy vấn chậm nhất của bạn, bọc nó bằng cacheAside với hạn ngắn, và đo lại. Nếu truy vấn đó vẫn chậm đến mức mỗi lần hụt cache là đau, hãy sửa chính nó trước, bài Truy vấn chậm? Cách đọc EXPLAIN và đặt index đúng chỗ bắt đầu từ đó. Còn nếu cache của bạn đứng sau một API công khai, bài Thiết kế API cho người khác dùng: báo lỗi, chia trang, thử lại và giới hạn lượt gọi nói về phần giao diện và giới hạn lượt gọi.



