Cache không chỉ là một tối ưu đặt trước một hàm chậm. Một request production có thể đi qua cache của trình duyệt, CDN, reverse proxy, process ứng dụng, Redis, query cache và buffer pool của database. Mỗi tầng có thể loại bỏ một phần công việc, nhưng cũng giữ một bản sao chưa đầy đủ của sự thật với key, vòng đời và cơ chế invalidation khác nhau.
Vì vậy, câu hỏi khó không phải là “có nên cache dữ liệu này không?” mà là “tầng nào sở hữu độ mới của representation này, và điều gì xảy ra khi tầng đó sai hoặc không hoạt động?” Thiết kế tốt bắt đầu từ read path, gán consistency contract rõ ràng cho từng bản sao, đồng thời xem invalidation, observability, security và test là một phần của tính năng.
Một request đi qua nhiều tầng cache
Mỗi tầng phục vụ một phạm vi khác nhau. Đặt cùng một object ở mọi nơi hiếm khi có ích: browser cache là riêng tư và gần một người dùng, còn distributed cache được nhiều instance ứng dụng chia sẻ. Buffer cache của database lưu page chứ không lưu business object; nó có thể đã loại bỏ physical I/O dù câu SQL vẫn phải chạy.
| Tầng | Nội dung thường gặp | Phạm vi | Cách điều khiển chính |
|---|---|---|---|
| Trình duyệt | Metadata validate HTML, CSS, JS, ảnh | Một user agent | Cache-Control, validator |
| CDN | Response public và static asset | Edge theo địa lý | HTTP header, purge API |
| Reverse proxy | Trang đã render, response từ upstream | Một region hoặc cluster | Proxy rule, surrogate key |
| Memory ứng dụng | Config đã parse, reference data nhỏ | Một process | Giới hạn kích thước, local TTL |
| Distributed cache | Session, entity, kết quả tính toán | Nhiều app instance | Key có namespace, TTL, eviction |
| Query/result cache | Kết quả query đắt | Service hoặc database | Invalidation theo dependency |
| Buffer pool | Page của index và table | Một database instance | Replacement policy của database |
Các tầng phía trên tránh network và toàn bộ công việc ứng dụng. Các tầng phía dưới giảm computation và storage I/O. Một CDN hit có thể bỏ qua cả origin path; một buffer-pool hit vẫn phải trả network round trip, planning hoặc lookup, lock và decode row. Hãy đo lượng công việc được loại bỏ, không chỉ đo việc một tầng có báo hit hay không.
Với một tầng cache, tỷ lệ cơ bản là:
Nhưng hit ratio 99% vẫn có thể kém nếu 1% miss là các request đắt nhất, hoặc nguy hiểm nếu hit đang trả authorization data cũ. Cần theo dõi latency và origin work theo từng loại kết quả bên cạnh tỷ lệ tổng.
HTTP caching là một protocol
HTTP đã định nghĩa freshness và validation. Asset có version trong tên như /assets/app.4f21c.js có thể được cache một năm vì khi byte thay đổi, URL mới sẽ được tạo:
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Content-Type: text/javascript; charset=utf-8
HTML có thể thay đổi cần policy khác. no-cache không có nghĩa là “không được lưu”; nó cho phép lưu nhưng bắt buộc revalidate trước khi dùng lại. ETag cho phép trình duyệt hoặc CDN hỏi origin xem bản sao còn hiện hành hay không:
GET /articles/caching HTTP/1.1
If-None-Match: "article-42-v17"
HTTP/1.1 304 Not Modified
ETag: "article-42-v17"
Cache-Control: public, max-age=60, stale-while-revalidate=300
Vary: Accept-Encoding
max-age=60 giữ response fresh trong 60 giây. Trong 300 giây tiếp theo, stale-while-revalidate cho phép cache trả ngay bản cũ trong lúc refresh ở background. Contract lúc này chuyển từ “luôn mới nhất” thành “thường không cũ quá năm phút và tail latency thấp.” Cách này phù hợp với bài viết public nhưng thường không chấp nhận được cho số dư, permission hoặc bước trừ inventory.
Vary đưa các request header được chọn vào HTTP cache key. Hãy dùng có chủ đích: Vary: Accept-Encoding có cardinality nhỏ và hữu hạn, trong khi Vary: User-Agent hoặc locale header không được chuẩn hóa có thể làm cache phân mảnh. Response chứa dữ liệu theo người dùng nhìn chung phải là private; secret và response dùng một lần có thể cần no-store.
CDN và reverse proxy cần một trust boundary đáng tin cậy. Không được cache response chỉ vì path có vẻ public. Origin nên tuyên bố cacheability, còn shared cache không được dùng response có Authorization hoặc Cookie trừ khi policy và key cô lập chúng tường minh. Surrogate key như article:42 có thể gom nhiều URL để purge có mục tiêu thay vì xóa toàn bộ edge.
Warning
Shared cache key bỏ sót tenant, authorization scope, locale, currency hoặc content encoding có thể trả representation của người này cho người khác. Key đầy đủ là một thuộc tính security, không chỉ là chi tiết performance.
Key, TTL và cache-aside
Cache key phải nhận diện chính xác representation và schema. Hãy đưa vào các chiều ổn định làm thay đổi kết quả, chuẩn hóa chúng và tăng version khi serialization hoặc ý nghĩa thay đổi:
type ArticleView = {
id: string;
tenantId: string;
locale: "en" | "vi";
title: string;
bodyHtml: string;
version: number;
};
function articleKey(input: {
tenantId: string;
articleId: string;
locale: ArticleView["locale"];
}): string {
const tenant = encodeURIComponent(input.tenantId.toLowerCase());
const article = encodeURIComponent(input.articleId);
return `article-view:v3:${tenant}:${article}:${input.locale}`;
}
Namespace v3 giúp deployment an toàn khi JSON cũ không còn tương thích. Tránh key tạo từ raw SQL, object serialization không ổn định hoặc text không giới hạn do attacker kiểm soát. Với canonical input dài, hãy hash nó và giữ một structured prefix đủ rõ cho vận hành.
TTL phải xuất phát từ freshness budget chứ không phải một con số tròn tùy ý. Nếu nguồn thay đổi với tốc độ change_rate lần mỗi giây và bản sao sống trong ttl_seconds, xác suất gần đúng có ít nhất một thay đổi trong thời gian đó là:
Ước lượng này giả định các thay đổi độc lập, nhưng buộc ta trả lời đúng câu hỏi: giá trị thay đổi thường xuyên đến đâu và reader được phép thấy giá trị trước đó bao lâu? Thêm jitter ngẫu nhiên để hàng nghìn key không hết hạn cùng lúc.
Cache-aside giữ database làm nguồn sự thật: đọc cache, load khi miss rồi populate. Implementation còn phải validate dữ liệu, giới hạn TTL và xử lý failure an toàn:
async function getArticle(input: {
tenantId: string;
articleId: string;
locale: ArticleView["locale"];
}): Promise<ArticleView | null> {
const key = articleKey(input);
try {
const cached = await redis.get(key);
if (cached !== null) return parseArticleView(cached);
} catch (error) {
metrics.increment("cache.read_error");
logger.warn({ error, keyPrefix: "article-view:v3" }, "cache read failed");
}
const article = await repository.findArticle(input);
if (article === null) {
await redis.set(key, JSON.stringify({ missing: true }), { EX: 15 });
return null;
}
const view = renderArticle(article);
const ttlSeconds = 300 + Math.floor(Math.random() * 60);
await redis.set(key, JSON.stringify(view), { EX: ttlSeconds });
return view;
}
Marker tồn tại ngắn cho dữ liệu không tìm thấy chính là negative caching. Nó bảo vệ origin khỏi lookup lặp lại cho ID không tồn tại, lỗi từ crawler và một phần enumeration traffic. TTL phải ngắn vì resource có thể sắp được tạo. Trong code thực tế, nên dùng tagged union để giá trị hợp lệ không bao giờ bị nhầm với sentinel.
Invalidation, stampede và consistency
Invalidation chỉ dựa vào TTL đơn giản nhưng dữ liệu có thể cũ tối đa bằng TTL. Delete-on-write fresh hơn: commit database transaction, sau đó xóa các key bị ảnh hưởng. Không được xóa trước commit vì reader chạy đồng thời có thể populate lại giá trị cũ trước khi write hiển thị. Ngay cả delete-after-commit vẫn có failure gap giữa thao tác database và cache.
Các chiến lược phổ biến cung cấp guarantee khác nhau:
| Chiến lược | Freshness | Failure mode | Trường hợp phù hợp |
|---|---|---|---|
| Chỉ TTL | Cũ tối đa bằng TTL | Dữ liệu cũ sau write | Dữ liệu public dẫn xuất |
| Delete on write | Thường fresh | Mất lệnh delete để lại entry cũ | Entity read với TTL ngắn |
| Write through | Cache cập nhật cùng write | Thứ tự dual-write | Write path được kiểm soát |
| Versioned key | Reader mới dùng version mới | Key cũ chiếm dung lượng | Deploy, revision bất biến |
| Event invalidation | Nhanh giữa nhiều service | Event trễ hoặc bị mất | Shared derived view |
Với yêu cầu strong consistency, đừng dùng cache trên critical read hoặc phải gắn read với database version. Để có read-your-writes, trả trực tiếp object vừa commit, mang theo minimum-version token, hoặc tạm bypass replica và cache. “Eventually consistent” chưa phải contract đầy đủ nếu không nêu độ trễ tối đa dự kiến và hành vi khi event backlog tăng.
Khi một hot key hết hạn, cache stampede có thể xảy ra: hàng trăm request miss cùng lúc và đều query database. Request coalescing chỉ cho phép một loader chạy trên mỗi key, các caller khác await cùng promise:
const inFlight = new Map<string, Promise<ArticleView | null>>();
async function coalescedLoad(
key: string,
load: () => Promise<ArticleView | null>,
): Promise<ArticleView | null> {
const existing = inFlight.get(key);
if (existing) return existing;
const pending = load().finally(() => inFlight.delete(key));
inFlight.set(key, pending);
return pending;
}
Cách này chỉ bảo vệ một process. Muốn bảo vệ xuyên process có thể dùng distributed lease ngắn, nhưng lock expiry và owner failure làm thiết kế phức tạp hơn. Stale-while-revalidate thường an toàn hơn: dùng soft expiry để refresh và hard expiry để từ chối, cho một worker refresh trong khi caller khác nhận dữ liệu stale. Thêm TTL jitter, chỉ warm các key đã chứng minh là hot, đồng thời giới hạn refresh concurrency để recovery traffic không đánh sập database.
Note
Cache làm thay đổi availability semantics. Phải quyết định rõ khi cache outage thì fail open xuống database, trả bounded stale data hay fail closed. Fail open mà không giới hạn tải origin có thể biến sự cố Redis thành sự cố database.
Cache database, vận hành và kiểm thử
Team ứng dụng thường thêm Redis trước khi xem query plan. Buffer pool của database đã giữ các page table và index thường dùng trong memory. Query vẫn chậm khi page đã warm có thể cần index tốt hơn, ít row hơn hoặc giảm decode thay vì thêm cache. Ngược lại, query-result cache có ích cho aggregate ổn định nhưng invalidation quá rộng có thể xóa sạch lợi ích đó.
Hãy quan sát riêng từng tầng. Ghi nhận hit, miss, stale, negative_hit, load_error và refresh_error; latency của cache lookup và origin load; số byte và item; eviction rate; tuổi của key; số waiter được coalesce; cùng database work đã tránh được. Eviction cao trong khi memory utilization thấp có thể do giới hạn số item. Hit ratio tăng nhưng latency cũng tăng có thể báo value quá lớn hoặc network bottleneck quanh hot key.
Log không được lộ key đầy đủ nếu key chứa identifier, còn cached value cần cùng encryption, access control, retention và quy trình xóa như nguồn. Ngăn cache poisoning bằng cách validate upstream response trước khi lưu. Giới hạn kích thước key và value, authenticate cache server, cô lập environment và tenant, và không bao giờ coi cache là bản duy nhất của dữ liệu durable.
Test behavior chứ không chỉ test rằng code có gọi Redis:
it("coalesces misses and invalidates only after commit", async () => {
repository.findArticle.mockResolvedValue(article);
const [first, second] = await Promise.all([
service.get(input),
service.get(input),
]);
expect(first).toEqual(second);
expect(repository.findArticle).toHaveBeenCalledTimes(1);
await service.update(input, { title: "New title" });
expect(transaction.commit).toHaveBeenCalledBefore(redis.del);
expect(redis.del).toHaveBeenCalledWith(articleKey(input));
});
Dùng fake clock để test trạng thái fresh, soft-expired và hard-expired. Bổ sung concurrent miss test, cache timeout, malformed value, race khi tạo negative cache, tenant isolation và integration test với cache client thật vì mock command signature có thể che lỗi production. Load test cần bao gồm cold cache, mass expiry, một hot key, và cache failure trong lúc origin limit đang hoạt động.
Những điểm cần nhớ
- Đặt dữ liệu ở tầng cao nhất có thể biểu diễn đúng scope; hit càng cao càng loại bỏ nhiều downstream work.
- Xem HTTP directive, chiều của key, TTL và thứ tự invalidation như một consistency contract.
- Dùng key có version, có giới hạn, nhận biết tenant và negative cache ngắn cho giá trị thật sự không tồn tại.
- Ngăn stampede bằng jitter, request coalescing, stale-while-revalidate và origin limit nghiêm ngặt.
- Query cache và database buffer pool giải quyết bài toán khác nhau; hãy xem plan và hành vi warm trước.
- Đo freshness lẫn lượng việc đã tránh, bảo vệ cached data như source data, và test expiry, concurrency, failure cùng isolation.
Cache thành công khi làm hệ thống rẻ hơn và nhanh hơn trong một ranh giới correctness được mô tả rõ. Nếu thiếu ranh giới đó, mỗi tầng mới chỉ tăng tốc happy path và tăng độ bất định ở mọi nơi còn lại.