Z-SOFT
KIẾN TRÚC · HIỆU NĂNG

Caching ba tầng cho SaaS multi-tenant: CDN, Redis và invalidation theo version

Thêm Redis không có nghĩa là bài toán cache đã xong. Bài viết này kể lại cách Z-EDU chia cache thành ba tầng: Cache-Control ở biên, Redis có single-flight và invalidation bằng version. Chúng tôi cũng nhắc lại một sự cố cấu hình cache từng suýt biến bài làm của thí sinh thành dữ liệu bị rò rỉ.

Đội kỹ thuật Z-SOFT 10 phút đọc

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ầngChặ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à getset. 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-policyallkeys-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

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.

Z-SOFT xây dựng những hệ thống như thế này

Kỹ thuật trong bài đang chạy thật trong sản phẩm của chúng tôi. Nếu doanh nghiệp của bạn cần một hệ thống chịu tải, bảo mật và vận hành được lâu dài, hãy trao đổi với đội kỹ thuật Z-SOFT.

Bài liên quan