Z-EDU là hệ thống LMS chúng tôi xây cho các trung tâm giáo dục. Backend dùng NestJS 11 cho REST
/api/v1, Prisma trên PostgreSQL, Redis, Next.js 16 cho web/admin, tất cả nằm trong một monorepo
pnpm + Turborepo và deploy qua GitLab CI xuống Portainer Swarm. Tải của hệ thống khá lệch: phần lớn là đọc, như
trang giới thiệu trung tâm, danh mục khoá học, đề test công khai; phần ghi ít hơn nhưng phải đúng tuyệt đối, như
bài nộp và điểm số.
Phản xạ ban đầu rất dễ hiểu: thêm Redis, bọc vài truy vấn nặng, rồi coi như xong bài toán cache. Chúng tôi cũng từng nghĩ như vậy, cho tới khi rút ra ba bài học quan trọng, trong đó có một bài học suýt thành sự cố bảo mật. Đây là ghi chép về ba tầng cache đang chạy thực tế, cùng những rủi ro nằm giữa các tầng đó.
1. Vì sao chỉ thêm Redis là chưa đủ
Redis nhanh, nhưng hãy nhìn kỹ đường đi của một request đã hit cache Redis: nó vẫn đi qua CDN, reverse proxy, vào tiến trình Node, chạy middleware, parse JWT, dựng ngữ cảnh tenant, chạy guard phân quyền, rồi mới tới controller để đọc Redis, serialize JSON và trả về. Bạn tiết kiệm được thời gian của PostgreSQL, nhưng vẫn trả gần như đủ chi phí cho vòng đời request trong Node: event loop, socket, middleware và tài nguyên của tiến trình API.
Khi một trung tâm chạy chiến dịch tuyển sinh và vài nghìn người cùng mở trang giới thiệu trong một phút, thứ quá
tải trước chưa chắc là PostgreSQL. Có khi API worker mới là điểm nghẽn. Nếu request vẫn đi hết vòng đời của ứng
dụng, Redis chỉ chuyển áp lực từ database sang API. Muốn giảm tải thật sự, cần chặn request từ trước khi nó chạm
tới API. Đó là việc của Cache-Control.
Tầng cache tốt nhất là tầng mà request không bao giờ tới được ứng dụng của bạn. Mọi tầng bên trong chỉ là lưới an toàn cho phần lưu lượng lọt qua.
Vì vậy chúng tôi xếp cache theo thứ tự từ ngoài vào trong. Mỗi tầng có một nhiệm vụ riêng và một đánh đổi riêng:
| Tầng | Chặn được gì | Cái giá phải trả |
|---|---|---|
1. Cache-Control tại CDN/Traefik |
Toàn bộ vòng đời request: mạng, Node, guard, DB | Không kiểm soát được thời điểm xoá; rủi ro phát nhầm dữ liệu riêng tư |
2. Redis (CacheService.wrap) |
Truy vấn DB; gộp các miss trùng khoá làm một | Vẫn tốn một vòng đời request Node; thêm một phụ thuộc hạ tầng |
3. Version key (bump) |
Dữ liệu cũ tồn tại sau khi admin sửa nội dung | Rác nằm lại trong Redis tới khi TTL hết; tốn thêm một lượt đọc version |
2. Tầng 1 — Cache-Control ở biên, và sự cố phải kể lại
Trong Z-EDU, tầng biên được điều khiển bằng một decorator: @HttpCache. Nó chỉ gắn
metadata, còn một interceptor sẽ đọc metadata đó và đặt header Cache-Control lên
response. Không có decorator thì không có cache — mặc định là no-store.
// http-cache.decorator.ts
export interface HttpCacheOptions {
maxAge: number; // giây — cho trình duyệt
sMaxAge?: number; // giây — cho CDN/proxy chia sẻ
staleWhileRevalidate?: number;
visibility?: 'public' | 'private';
vary?: string[];
}
export const HTTP_CACHE = 'http_cache';
export const HttpCache = (opts: HttpCacheOptions) => SetMetadata(HTTP_CACHE, opts);
// Dùng: chỉ opt-in đúng route thật sự công khai và bất biến theo người dùng
@Public()
@HttpCache({ maxAge: 60, sMaxAge: 300, staleWhileRevalidate: 600, visibility: 'public', vary: ['Accept-Encoding'] })
@Get('landing/:tenantSlug')
getLanding(@Param('tenantSlug') slug: string) {
return this.landingService.get(slug);
}
Bản đầu tiên của chúng tôi không như vậy. Nó cố "thông minh" hơn: một interceptor toàn cục thấy route nào có
@Public() thì tự động gắn Cache-Control: public. Lập luận nghe hợp lý:
@Public() nghĩa là không cần đăng nhập; không cần đăng nhập thì chắc ai xem cũng như nhau; vậy cache
chung được.
Lập luận đó sai ở một điểm quan trọng. Trong Z-EDU có route
GET /exams/public/attempts/:id: thí sinh làm bài test công khai không cần tạo tài khoản, họ nhận một
id cho lần làm bài và dùng chính id đó để đọc lại bài của mình. Route này gắn
@Public() vì nó không yêu cầu JWT. Nhưng phản hồi vẫn là dữ liệu của một người cụ thể.
Interceptor toàn cục nhìn thấy @Public() rồi gắn
Cache-Control: public, s-maxage=300 lên phản hồi. Proxy chia sẻ giữ lại bài làm của một thí sinh.
Người tiếp theo truy cập cùng URL có thể nhận lại nội dung đó. Đây không còn là lỗi hiển thị do cache; đây là rò
rỉ dữ liệu.
@Public() trả lời câu hỏi "có cần token để gọi không?". Nó không trả lời câu hỏi
"phản hồi này có giống nhau với mọi người không?". Hai câu hỏi khác nhau, và chỉ câu thứ hai mới
quyết định được có nên cache chung hay không.
Bài học rút ra và trở thành luật trong repo: cache ở biên phải opt-in từng route, không
được suy ra từ một decorator khác. Việc thêm một dòng @HttpCache cho từng route
rẻ hơn rất nhiều so với rủi ro phát nhầm dữ liệu riêng tư. Chúng tôi chấp nhận hơi lặp một chút để hệ
thống an toàn theo mặc định.
public, private và vai trò của Vary
Cần phân biệt rõ ba khái niệm dễ bị nhập nhằng. Cache-Control: public cho phép cache dùng chung như
CDN, proxy doanh nghiệp hoặc proxy của ISP lưu bản sao và phát lại cho người khác. private chỉ cho
phép cache riêng của trình duyệt người dùng lưu; CDN phải bỏ qua. Còn no-store nghĩa là không lưu ở
đâu cả. Với route attempts/:id, lựa chọn đúng là no-store, vì đây là dữ liệu riêng của
một người.
Vary là mảnh ghép thứ hai. Nó nói cho cache biết phản hồi phụ thuộc vào header nào. Nếu phản hồi thay
đổi theo Accept-Language mà bạn quên Vary: Accept-Language, người dùng tiếng Việt có thể
nhận trang tiếng Anh của người truy cập trước. Với SaaS multi-tenant, rủi ro còn lớn hơn: nếu tenant được xác định
bằng header nhưng cache không Vary theo header đó, CDN có thể phát nội dung của trung tâm A cho trung
tâm B. Cách an toàn hơn là đặt tenant ngay trong đường dẫn hoặc hostname, vì đây là phần CDN chắc chắn dùng trong
khoá cache.
3. Tầng 2 — Redis và single-flight chống cache stampede
Không phải route nào cũng có thể cache ở biên. Tổng hợp tiến độ của một học viên là dữ liệu cá
nhân, phải qua guard, không thể để CDN chạm vào — nhưng nó lại là truy vấn nặng và bị gọi liên
tục. Đó là địa bàn của tầng hai: Redis, qua một lớp mỏng CacheService.
Điểm mấu chốt của CacheService không phải là get và set. Phần quan trọng là
wrap, cụ thể là khả năng single-flight. Hãy hình dung một khoá cache nóng, ví dụ danh mục
khoá học của một trung tâm lớn, mất khoảng nửa giây để dựng lại. Khi TTL hết hạn đúng giờ cao điểm, hàng trăm
request đồng thời thấy cache miss. Nếu không có cơ chế gộp, cả trăm request cùng chạy truy vấn nặng xuống DB.
PostgreSQL nhận một đợt tải dồn, connection pool cạn, độ trễ tăng, rồi các khoá nóng khác cũng bắt đầu hết hạn
theo. Đây là cache stampede, hay thundering herd.
Cách chữa là gộp các lần miss trùng khoá lại làm một: chỉ request đầu tiên được xuống DB, những request còn lại chờ đúng cái promise đó.
@Injectable()
export class CacheService {
// Bản đồ các lần "đang bay" — sống trong bộ nhớ tiến trình
private readonly inflight = new Map<string, Promise<unknown>>();
async wrap<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T> {
const cached = await this.redis.get(key).catch(() => null); // Redis lỗi -> coi như miss
if (cached) return JSON.parse(cached) as T;
// Đã có một request khác đang nạp đúng khoá này -> bám theo, không xuống DB nữa
const flying = this.inflight.get(key);
if (flying) return flying as Promise<T>;
const task = loader()
.then(async (value) => {
// set là best-effort: Redis chết cũng không được làm hỏng response
await this.redis
.set(key, JSON.stringify(value), 'EX', ttlSec)
.catch(() => undefined);
return value;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, task);
return task;
}
}
Cần nói rõ giới hạn: inflight là một Map trong bộ nhớ tiến trình, nên nó chỉ gộp được
các cache miss trong cùng một instance API. Nếu chạy bốn replica, lúc key hết hạn, trường hợp xấu nhất
vẫn có bốn truy vấn xuống DB thay vì một. Nhưng bốn khác rất xa bốn trăm. Với chúng tôi, mức giảm đó đủ tốt và
đổi lại hệ thống đơn giản hơn: không cần khoá phân tán, không có nguy cơ deadlock, không có chuyện một tiến trình
chết khi đang giữ lock làm các tiến trình khác chờ mãi. Distributed lock trên Redis có thể gộp xuống đúng một
truy vấn, nhưng đổi lại là một lớp phức tạp mới để vận hành. Chúng tôi chọn giải pháp đơn giản và chỉ nâng cấp khi
số liệu bắt buộc.
4. Tầng 3 — Invalidation theo version, và vì sao KEYS/SCAN là bẫy
Bài toán đặt ra là: admin sửa mô tả một khoá học. Có hàng chục khoá cache liên quan tới nội dung công khai của trung tâm đó — danh mục, trang landing, chi tiết khoá, danh sách đề test. Làm sao xoá hết chúng?
Câu trả lời đầu tiên nhiều người nghĩ tới là quét theo pattern: KEYS zedu:pub:* rồi xoá. Không nên
làm vậy. KEYS là lệnh O(N) chạy trên thread đơn của Redis: nó duyệt toàn bộ keyspace, và trong thời
gian đó Redis gần như không phục vụ lệnh khác. Với vài triệu khoá, chỉ một thao tác sửa mô tả khoá học của admin
cũng có thể làm các lệnh khác timeout. SCAN đỡ hơn vì chia nhỏ thành nhiều lượt, nhưng vẫn phải duyệt
qua keyspace, tốn nhiều round-trip và không cho bạn một thời điểm hoàn tất rõ ràng. Dùng SCAN để xoá
cache biến một thao tác đáng ra tức thời thành một tác vụ nền khó tin cậy.
Cách chúng tôi làm là không xoá trực tiếp các khoá cũ. Thay vào đó, nhúng một số version vào chính khoá cache. Khi cần invalidate cả một "vùng", chỉ việc tăng số đó lên: mọi khoá cũ lập tức trở thành khoá không ai còn hỏi tới nữa, và tự chết khi TTL hết.
@Injectable()
export class CacheVersionService {
// micro-cache trong process: tránh 1 round-trip Redis cho MỌI request
private readonly local = new Map<string, { v: number; exp: number }>();
private static readonly MICRO_TTL_MS = 2000; // <= 2s
async get(scope: string): Promise<number> {
const now = Date.now();
const hit = this.local.get(scope);
if (hit && hit.exp > now) return hit.v;
// Redis chết -> v = 0. Cache vẫn chạy, chỉ là không nhận được lệnh bump.
const raw = await this.redis.get(`ver:${scope}`).catch(() => null);
const v = Number(raw ?? 0) || 0;
this.local.set(scope, { v, exp: now + CacheVersionService.MICRO_TTL_MS });
return v;
}
async bump(scope: string): Promise<void> {
await this.redis.incr(`ver:${scope}`).catch(() => undefined);
this.local.delete(scope); // instance này thấy ngay; các instance khác thấy sau <= 2s
}
}
// Khoá cache mang version theo vùng + tenant
async function publicCourseList(tenantId: string) {
const v = await this.versions.get(`pub:${tenantId}`);
return this.cache.wrap(
`zedu:v${v}:pub:${tenantId}:courses`,
300,
() => this.prisma.course.findMany({ where: { tenantId, published: true } }),
);
}
// Admin sửa nội dung công khai -> toàn bộ vùng pub của tenant đó thành rác
async function onPublicContentChanged(tenantId: string) {
await this.versions.bump(`pub:${tenantId}`);
}
Có vài điểm đáng nói. Thứ nhất, version được chia theo tenant, ví dụ pub:${tenantId}, nên một trung
tâm sửa nội dung không làm lạnh cache của các trung tâm khác. Trong SaaS multi-tenant, nếu một vùng invalidation
ôm cả hệ thống, một khách hàng sửa nhiều nội dung có thể kéo tụt hiệu năng của các khách hàng còn lại.
Thứ hai, bump là một lệnh INCR — O(1), không chặn, không quan tâm
keyspace to cỡ nào. Chi phí invalidation không còn phụ thuộc vào số khoá cần xoá. Đó chính là lý
do chúng tôi chọn cách này.
Thứ ba là micro-cache. Nếu request nào cũng phải hỏi Redis "version hiện tại là mấy?" thì ta vừa thêm một round-trip vào đường nóng, đúng thứ cache sinh ra để loại bỏ. Vì vậy version được giữ trong bộ nhớ tiến trình tối đa 2 giây. Đánh đổi là sau khi admin bấm Lưu, một instance khác có thể còn phát bản cũ trong tối đa 2 giây. Với nội dung công khai của một LMS, đây là mức trễ chấp nhận được, và nó cho chúng tôi một cam kết rõ ràng: sửa xong sẽ hiện sau không quá 2 giây.
Cuối cùng là chi phí thật của mô hình version: khoá cũ không biến mất ngay mà nằm lại trong Redis tới khi TTL hết.
Bạn đổi một chút bộ nhớ lấy thao tác invalidation O(1), không quét keyspace và không làm nghẽn Redis. Nếu đặt
maxmemory-policy là allkeys-lru, phần rác đó còn có thể tự bị đẩy ra khi thiếu bộ nhớ.
Với chúng tôi, đây là đánh đổi hợp lý.
5. Khi Redis chết: fail-open
Hãy để ý một chi tiết lặp lại trong toàn bộ code ở trên: mọi lời gọi Redis đều có nhánh xử lý lỗi. Đó không phải sự cẩu thả, mà là quyết định kiến trúc.
Redis trong Z-EDU không phải nguồn dữ liệu gốc. Nguồn sự thật là PostgreSQL. Redis chỉ là lớp tối ưu. Vì vậy khi Redis gặp sự cố, hành vi đúng không phải là trả 500 cho người dùng, mà là fail-open: mọi thứ xuống thẳng DB. Hệ thống chậm hơn và DB gánh nặng hơn, nhưng kết quả vẫn đúng. Học viên vẫn xem được bài, vẫn nộp được bài thi. Chúng tôi áp dụng cùng nguyên tắc cho rate-limit: bộ đếm theo IP nằm trên Redis, và Redis lỗi thì rate-limit fail-open thay vì từ chối mọi request.
Cache là tối ưu, không phải nguồn thật sự. Nếu hệ thống của bạn ngừng hoạt động khi cache chết, thì cái bạn đang có không phải là cache — đó là một cơ sở dữ liệu không có backup.
Nguyên tắc này có một điều kiện rất quan trọng: DB phải chịu được tải khi không có cache. Fail-open chỉ có ý nghĩa nếu khi Redis biến mất, PostgreSQL vẫn chịu được toàn bộ lưu lượng, dù chậm hơn. Nếu bạn cache vì DB đã quá tải sẵn, fail-open chỉ chuyển sự cố Redis thành sự cố PostgreSQL. Bài kiểm tra rất đơn giản và nên chạy trước khi production bắt bạn chạy: tắt Redis ở staging dưới tải giống production, xem hệ thống còn đứng vững không. Nếu không, cache đã âm thầm trở thành thành phần bắt buộc; lúc đó cần sửa DB trước, đừng chỉ chồng thêm cache.
Ở đây tầng một lại một lần nữa cứu chúng tôi. Vì Cache-Control đẩy phần lớn lưu
lượng đọc công khai ra ngoài biên, CDN vẫn tiếp tục phục vụ nội dung đã cache ngay cả khi Redis
lẫn API đều đang chật vật. Ba tầng độc lập nghĩa là chúng hỏng độc lập — và đó chính là lý do
sâu xa để tách chúng ra thay vì dồn hết vào một chỗ.
6. Checklist rút ra
- Đo trước khi cache. Nếu nút thắt là số worker của API chứ không phải DB, thêm Redis chỉ làm bạn thấy dễ chịu chứ không cứu được gì. Cache ở biên mới cứu.
-
Cache biên phải opt-in từng route. Không bao giờ suy ra chính sách cache từ
một decorator khác (như
@Public). Mặc định làno-store. -
Hỏi "phản hồi này có giống nhau với mọi người không?" trước khi gõ
public. Nếu câu trả lời là "không" hoặc "chưa chắc" thì dùngprivatehoặcno-store. -
Mọi thứ ảnh hưởng tới nội dung phản hồi phải nằm trong khoá cache — qua URL,
hostname, hoặc
Vary. Với hệ thống multi-tenant, hãy để tenant nằm trong đường dẫn hoặc hostname; đừng đặt cược vào việc cấu hìnhVaryluôn đúng. -
Mọi khoá nóng phải đi qua
wrap(single-flight). Một khoá nóng không có chống stampede là một quả bom hẹn giờ đặt đúng vào lúc TTL hết hạn. -
Invalidate bằng version, không bằng
KEYS/SCAN.INCRlà O(1); quét keyspace thì không, vàKEYScòn chặn cả Redis. Chia version theo tenant để một khách thuê không làm lạnh cache của khách thuê khác. - Fail-open, và hãy diễn tập nó. Mọi lời gọi Redis đều có nhánh dự phòng xuống DB. Tắt Redis ở staging dưới tải thật — nếu hệ thống sập, cache của bạn đã lặng lẽ trở thành nguồn sự thật.
Kết luận
Ba tầng, ba nhiệm vụ tách bạch. Cache-Control gánh phần tải đọc lớn nhất, nhưng cũng là tầng cần cẩn
trọng nhất vì phản hồi đã phát ra thì không lấy lại được. Redis với wrap che chắn cho PostgreSQL và
tránh việc nhiều request cùng dội xuống DB khi cache hết hạn. Version key cho phép invalidation gần như tức thời
mà không phải quét keyspace. Bên dưới tất cả là một nguyên tắc duy nhất: PostgreSQL là nguồn sự thật, phần còn lại
chỉ giúp hệ thống nhanh hơn.
Sự cố attempts/:id là điều chúng tôi vẫn nhắc lại với người mới. Nó cho thấy cache không chỉ là bài
toán hiệu năng; đặt sai một header, nó trở thành bài toán bảo mật. Trong SaaS multi-tenant, khoảng cách giữa
"nhanh" và "phát dữ liệu của người này cho người khác" đôi khi chỉ là một chữ: public.