Z-SOFT
KIẾN TRÚC · CHỊU TẢI

Chịu tải cho SaaS: vì sao auto scale không cứu được thiết kế sai

Auto scale chỉ hiệu quả khi thiết kế bên dưới đã đủ gọn. Bài viết này nói về những việc cần làm trước khi bật auto scale: bỏ việc thừa khỏi đường request, tách worker khỏi API bằng BullMQ, giữ tiến trình stateless và cảnh báo theo lượng công việc tồn đọng thay vì chỉ nhìn CPU. Các ví dụ đều lấy từ Z-EDU đang vận hành thực tế.

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

Khi hệ thống bắt đầu chậm, phản xạ đầu tiên của nhiều đội là mở bảng điều khiển hạ tầng và tăng số replica. Cách này dễ, nhanh, và trong một số trường hợp là đúng. Vấn đề nằm ở phần còn lại: thêm instance không sửa được nguyên nhân gốc, mà chỉ làm một thiết kế chưa tối ưu chạy song song nhiều hơn.

1. Auto scale không sửa được thiết kế sai

Lấy một ví dụ có thật trong Z-EDU trước khi chúng tôi sửa. Trình phát video gửi ping tiến độ mỗi 15 giây cho từng học viên. Đường xử lý cũ chạm database 4 truy vấn cho mỗi ping; 20 ping của một buổi học là khoảng 80 truy vấn xuống Postgres. Nhân với số học viên xem đồng thời, API không còn chỉ phục vụ HTTP nữa mà còn liên tục đẩy truy vấn xuống DB.

Bây giờ giả sử API bắt đầu chậm và bạn nhân đôi số instance. Chuyện gì xảy ra? Mỗi instance vẫn tạo 80 truy vấn cho mỗi phiên xem. Bạn vừa nhân đôi khả năng đẩy truy vấn xuống một Postgres duy nhất. Latency không giảm, p99 xấu đi, connection pool cạn, và giờ bạn có gấp đôi số tiến trình tranh nhau cùng một tài nguyên khó scale ngang. Đây là điểm mấu chốt mà nhiều bài viết về "scalability" bỏ qua:

Auto scale chỉ nhân bản tầng bạn scale. Nó không nhân bản Postgres, không nhân bản Redis, không nhân bản băng thông ổ đĩa. Nếu nút thắt cổ chai nằm ở tầng không nhân bản được, thêm instance chỉ làm nút thắt đó siết chặt hơn — nhanh hơn.

Trình tự đúng là ngược lại. Trước khi hỏi "cần bao nhiêu instance", hãy hỏi "mỗi request thật sự phải làm bao nhiêu việc". Phần lớn API chậm mà chúng tôi từng gỡ có cùng một hình dạng: chúng làm quá nhiều việc không cần thiết ngay trên đường request đồng bộ. Có hai cách bỏ việc đó ra: không để request đến được API bằng cache, và không làm việc phụ ngay lúc đó bằng hàng đợi. Khi hai việc này đã xong, auto scale mới có ý nghĩa, vì lúc đó thứ bạn nhân bản là một tiến trình gọn nhẹ, không phải một tiến trình liên tục tạo thêm áp lực cho DB.

2. Tách một số xử lý ra khỏi request

Tầng chịu tải chính của Z-EDU không phải API, cũng không phải Redis. Đó là header Cache-Control đặt bằng decorator @HttpCache trên các route công khai. CDN/Traefik trả response ngay ở biên, request không bao giờ đi tới NestJS. Một request được xử lý ở biên gần như không tốn gì với backend: không connection, không CPU, không truy vấn Prisma. Không có tối ưu trong code nào tốt hơn việc không phải chạy code đó.

Nhưng cache ở biên là con dao hai lưỡi, và chúng tôi từng suýt trả giá vì nó. Cache đại trà mọi route @Public nghe hợp lý cho tới khi GET /exams/public/attempts/:id, bài làm của một thí sinh, bị proxy giữ lại và có thể phát cho người khác. Kết luận rút ra: cache ở biên phải opt-in từng route, không mặc định theo nhóm. "Public" trong xác thực nghĩa là không cần JWT; "public" trong cache nghĩa là ai xem cũng giống nhau. Hai khái niệm này không giống nhau.

Tầng thứ hai là Redis qua CacheService.wrap. Điểm quan trọng của wrap không chỉ là lưu kết quả, mà là gộp các lần miss trùng khoá thành một lần tính (single-flight). Khi một khoá nóng hết hạn, hàng trăm request đồng thời sẽ không cùng dội xuống DB; chỉ một request đi tính, số còn lại chờ kết quả đó. Đây là cơ chế chống cache stampede, rất quan trọng đúng vào thời điểm cache hết hạn. Chi tiết về ba tầng cache, invalidation theo version và vì sao chúng tôi không dùng SCAN/KEYS, chúng tôi đã viết riêng trong Cache ba tầng cho SaaS multi-tenant.

3. Tách worker khỏi API: BullMQ và nguyên tắc "cùng image, khác vai trò"

Cache xử lý phía đọc. Phía ghi cần một cơ chế khác: hàng đợi. Z-EDU dùng BullMQ với hai hàng đợi có vai trò rõ: zedu-maintenance (dọn tiến độ, job định kỳ) và zedu-audit (nhật ký thao tác admin).

Hàng đợi audit là ví dụ rõ nhất về "việc không cần nằm trên đường request". Trước đây mỗi thao tác admin ghi thêm 2 truy vấn nhật ký ngay trong request. Người dùng bấm "Lưu" và phải chờ cả phần ghi log xong mới nhận response. Nhật ký là thứ không ai đọc trong 200ms tiếp theo, nên bắt request chờ nó là một đánh đổi tệ. Đưa vào hàng đợi, request trả về sớm hơn, worker ghi sau.

// audit.service.ts — nhật ký rời khỏi đường request
@Injectable()
export class AuditService {
  constructor(@InjectQueue('zedu-audit') private readonly queue: Queue) {}

  // Gọi từ controller. Không await DB, chỉ await enqueue.
  async record(entry: AuditEntry): Promise<void> {
    await this.queue.add('write', entry, {
      removeOnComplete: 1000,
      removeOnFail: 5000,
      attempts: 3,
      backoff: { type: 'exponential', delay: 1000 },
    });
  }
}

// audit.processor.ts — chạy trong worker, KHÔNG chạy nếu WORKER_ENABLED=0
@Processor('zedu-audit')
export class AuditProcessor extends WorkerHost {
  async process(job: Job<AuditEntry>): Promise<void> {
    // Ghi theo lô, idempotent theo requestId để retry không nhân đôi bản ghi.
    await this.prisma.auditLog.createMany({
      data: [toRow(job.data)],
      skipDuplicates: true,
    });
  }
}

Ba chi tiết trong đoạn trên đáng nói ra thành lời, vì thiếu chúng thì hàng đợi biến từ giải pháp thành nguồn sự cố mới:

Bây giờ tới phần quan trọng về vận hành. Worker của Z-EDU chạy chung tiến trình với API, và tắt được bằng biến môi trường WORKER_ENABLED=0. Nghe có vẻ như một thoả hiệp chưa đẹp — sao không tách hẳn service riêng cho sạch? Vì tách hẳn nghĩa là hai codebase, hai pipeline build, hai image, hai bộ cấu hình phải giữ đồng bộ. Với một đội nhỏ, đó là chi phí thật, trả hàng ngày.

Cách chúng tôi làm: một image, nhiều vai trò. Cùng một artifact, vai trò do biến môi trường quyết định lúc chạy.

// main.ts — vai trò quyết định lúc chạy, không phải lúc build
const WORKER_ENABLED = process.env.WORKER_ENABLED !== '0';
const API_ENABLED = process.env.API_ENABLED !== '0';

const app = await NestFactory.create(AppModule, { bufferLogs: true });

if (API_ENABLED) {
  app.setGlobalPrefix('api/v1');
  await app.listen(3000);
} else {
  await app.init(); // worker thuần: không mở cổng HTTP
}

// AppModule chỉ nạp các *Processor khi WORKER_ENABLED,
// nên tiến trình API-only không hề đăng ký consumer nào với BullMQ.

Ngày thường, một service duy nhất chạy cả hai vai trò: đơn giản và ít thứ để theo dõi. Khi hàng đợi bắt đầu tồn đọng, chúng tôi dựng thêm một service worker-only dùng chính image đó, chỉ khác biến môi trường. Không sửa code, không build lại, không deploy lại API.

# docker-compose.yml (Swarm) — cùng image, khác vai trò
services:
  api:
    image: registry.example/zedu:${TAG}
    environment:
      API_ENABLED: "1"
      WORKER_ENABLED: "0"   # API thuần: chỉ phục vụ HTTP
    deploy:
      replicas: 3
      update_config: { order: start-first, parallelism: 1 }

  worker:
    image: registry.example/zedu:${TAG}   # CHÍNH image đó
    environment:
      API_ENABLED: "0"
      WORKER_ENABLED: "1"   # worker thuần: chỉ tiêu thụ hàng đợi 
    deploy:
      replicas: 2           # scale độc lập với api

Giá trị thật của thiết kế này là việc scale trở nên dễ quyết định hơn. Không cần đổi kiến trúc, không cần merge PR mới; chỉ cần đổi số replica. Đổi lại, đội phát triển phải giữ kỷ luật: mọi khác biệt giữa hai vai trò chỉ nằm trong biến môi trường, không rẽ nhánh theo hostname hay build flag trong code. Nếu vi phạm điều đó, image không còn là một artifact duy nhất và lợi ích biến mất.

Giới hạn cần nói thẳng: mô hình này không cho bạn cô lập lỗi ở mức tiến trình khi chạy gộp. Một worker ngốn RAM sẽ ảnh hưởng tới API cùng tiến trình. Đó chính là lý do WORKER_ENABLED tồn tại — nó là cái van bạn mở khi tải tăng, không phải thứ trang trí.

4. Scale ngang chỉ hoạt động khi state nằm ở ngoài

Có một điều kiện tiên quyết mà auto scale ngầm giả định nhưng không kiểm tra giúp bạn: mọi instance phải tương đương nhau. Request đi vào instance nào cũng cho cùng kết quả. Điều đó chỉ đúng khi tiến trình API không giữ state nào quan trọng trong RAM của chính nó.

Trong Z-EDU, state được đẩy ra ngoài một cách có chủ ý: session/JWT không cần lưu server-side, dữ liệu nghiệp vụ ở Postgres, file ở RustFS (S3-compatible, client PUT thẳng qua presigned URL), còn state tạm và tốc độ cao ở Redis. Tiến trình Node là thứ có thể giết đi lúc nào cũng được.

Ví dụ rõ nhất là rate-limit. Nếu bộ đếm request theo IP nằm trong RAM tiến trình — kiểu một Map<string, number> — thì với 3 replica, mỗi IP được phép gọi gấp 3 lần hạn mức bạn nghĩ. Tệ hơn, hạn mức thực tế trở nên ngẫu nhiên, phụ thuộc load balancer ném request vào đâu. Rate-limit của chúng tôi đếm theo IP qua Redis, nên hạn mức là một con số toàn cục, không phụ thuộc số instance.

Đây là bài kiểm tra đơn giản trước khi bật auto scale: nếu tắt ngẫu nhiên một instance ngay lúc đang có tải, có ai mất gì không? Nếu câu trả lời là "một số người bị đăng xuất", "một số upload dở dang hỏng", hoặc "bộ đếm sai" — thì bạn chưa stateless, và thêm instance sẽ tạo lỗi thay vì giảm tải.

5. Rate-limit và quyết định fail-open

Đưa rate-limit ra Redis giải quyết được vấn đề đếm toàn cục, nhưng tạo ra một câu hỏi mới: khi Redis gặp sự cố thì làm gì? Câu hỏi này không có một đáp án đúng cho mọi hệ thống.

Bạn chỉ có hai lựa chọn. Fail-closed: không kiểm tra được hạn mức thì từ chối request. Fail-open: không kiểm tra được thì cho qua. Z-EDU chọn fail-open.

// rate-limit.guard.ts — Redis chết không được phép làm sập API
async canActivate(ctx: ExecutionContext): Promise<boolean> {
  const req = ctx.switchToHttp().getRequest();
  const key = `rl:${routeOf(ctx)}:${clientIp(req)}`;

  try {
    const hits = await this.redis.incr(key);
    if (hits === 1) await this.redis.expire(key, WINDOW_SEC);
    if (hits > LIMIT) throw new ThrottlerException();
    return true;
  } catch (err) {
    if (err instanceof ThrottlerException) throw err; // vượt hạn mức: vẫn chặn
    // Redis không phản hồi -> FAIL-OPEN, kèm log để cảnh báo nổ.
    this.logger.error({ err }, 'rate-limit backend down, failing open');
    this.metrics.increment('ratelimit.fail_open');
    return true;
  }
}

Lý do rất thực dụng: Redis là dependency của rate-limit, không phải của nghiệp vụ. Nếu Redis lỗi mà chúng tôi fail-closed, một thành phần phụ trợ có thể khiến toàn bộ hệ thống từ chối người dùng thật. Cùng triết lý với phần còn lại của Z-EDU: Redis lỗi thì cache miss xuống thẳng DB (chậm hơn, nhưng đúng), write-behind ghi thẳng DB. Redis là lớp tăng tốc, không phải nguồn sự thật.

Nhưng cần nói rõ đánh đổi, vì đây không phải lựa chọn miễn phí: trong khoảng thời gian Redis lỗi, hệ thống gần như không có rate-limit. Kẻ tấn công có thể cố làm Redis quá tải trước rồi lợi dụng khoảng trống đó. Với Z-EDU, nơi các endpoint nhạy cảm còn được bảo vệ bằng JWT, RBAC và các hạn mức chốt ở server, đánh đổi này chấp nhận được.

Ranh giới nằm ở đâu? Fail-closed khi rate-limit là biện pháp kiểm soát an ninh duy nhất trên đường đó. Cụ thể: endpoint đăng nhập và quên mật khẩu (rate-limit chính là thứ chặn brute-force), endpoint gửi OTP/SMS/email (mỗi request tốn tiền thật), endpoint gọi sang bên thứ ba có hạn ngạch. Ở những chỗ đó, từ chối request an toàn hơn nhiều so với mở cửa. Quy tắc thực dụng: fail-open ở nơi rate-limit bảo vệ hiệu năng, fail-closed ở nơi nó bảo vệ tiền bạc hoặc tài khoản.

6. Quan sát đúng chỉ số: CPU thấp không có nghĩa là khoẻ

Khi đẩy việc vào hàng đợi, bạn cũng đẩy một phần lỗi ra khỏi tầm mắt. Trước kia, xử lý lỗi có thể làm request trả 500 và bạn biết ngay. Sau khi tách worker, API vẫn có thể trả 200, dashboard vẫn xanh, nhưng dữ liệu lặng lẽ không được ghi. Đây là cái giá của kiến trúc bất đồng bộ, và cách trả giá là quan sát đúng chỗ.

Trong Z-EDU, /health trả về pendingProgress: số bản ghi tiến độ đang chờ ghi xuống DB. Bình thường con số này dao động quanh 0, vì worker dồn xuống Postgres mỗi 5 giây. Nếu nó tăng lên và đứng yên ở mức cao, gần như chắc chắn worker đã chết hoặc bị kẹt. Đó là chỉ số chúng tôi dùng để dựng cảnh báo.

// health.controller.ts — health check nói lên điều gì đó có ích
@Get('/health')
async health() {
  const [pendingProgress, auditWaiting, auditFailed] = await Promise.all([
    this.progressBuffer.size(),                 // key Redis chờ flush
    this.auditQueue.getWaitingCount(),
    this.auditQueue.getFailedCount(),
  ]);

  return {
    status: 'ok',
    pendingProgress,   // bình thường ~0. Tăng lên & ở lì => worker chết.
    auditWaiting,
    auditFailed,
    workerEnabled: process.env.WORKER_ENABLED !== '0',
  };
}

Từ đây rút ra nguyên tắc chung, và nó là thứ giá trị nhất trong cả bài viết này:

Hãy cảnh báo trên chỉ số phản ánh công việc tồn đọng: độ sâu hàng đợi, độ trễ xử lý, số bản ghi chờ ghi. CPU thấp mà hàng đợi phình to vẫn là một hệ thống đang gặp sự cố: nó không bận, vì nó đã ngừng làm việc.

CPU là chỉ số nguyên nhân, không phải chỉ số hậu quả. Nó chỉ hữu ích khi tải là compute-bound. Với hệ thống SaaS điển hình — I/O-bound, phần lớn thời gian là chờ DB và Redis — CPU có thể nằm dưới 20% trong khi p99 latency là 8 giây vì connection pool đã cạn. Auto scale dựa trên CPU trong tình huống đó sẽ không kích hoạt, và bạn sẽ ngồi nhìn dashboard xanh trong lúc người dùng gọi điện.

Chỉ số nên cảnh báo Vì sao nó đáng tin Chỉ số dễ gây hiểu nhầm
pendingProgress tăng lên và đứng yên Trực tiếp nói worker không tiêu thụ được. Không có cách nào diễn giải khác. CPU của API — worker chết không làm CPU tăng, nó làm CPU giảm.
Queue depth (getWaitingCount) tăng monotonic Tốc độ nạp > tốc độ xử lý. Đây là dấu hiệu hệ thống sắp quá tải. Throughput (req/s) — vẫn có thể cao khi hệ thống trả lỗi rất nhanh.
Số job failed sau khi hết attempts Mất dữ liệu thật sự, đã hết đường retry tự động. Tỉ lệ lỗi HTTP — job bất đồng bộ chết không sinh ra lỗi HTTP nào.
p99 latency theo từng route Bắt được đuôi phân phối, nơi người dùng thật sự đau. Latency trung bình — che giấu hoàn toàn phần đuôi.
Số connection DB đang dùng / kích thước pool Nút thắt thật của phần lớn API. Cạn pool là chậm, dù CPU rảnh. Memory của container — thường phẳng lì cho tới đúng lúc OOM.
ratelimit.fail_open khác 0 Redis đang có vấn đề bạn đang không có rate-limit. Uptime của cổng HTTP — API vẫn trả 200 khi worker đã chết từ lâu.

7. Triển khai: một image, một domain, nhiều vai trò

Z-EDU là monorepo pnpm + Turborepo, triển khai qua GitLab CI xuống Portainer Swarm. Toàn bộ monorepo build ra một image, và hệ thống được phục vụ trên một domain với định tuyến ở tầng proxy: /api đi tới API NestJS, /admin tới Next.js admin, / tới Next.js web.

Lợi ích không phải là "đẹp về kiến trúc" — mà là số thứ có thể sai lệch giảm xuống. Một tag image duy nhất mô tả trạng thái của cả hệ thống; rollback là đổi một biến TAG. Không có chuyện API tag v1.4.2 chạy cùng admin tag v1.3.9 rồi vỡ vì hợp đồng API lệch nhau. Một domain nghĩa là không CORS, cookie không cần cấu hình cross-site, không có tầng cấu hình nào để quên.

Trade-off phải trả: image to hơn mức tối thiểu (worker-only vẫn mang theo bundle Next.js), và mọi thay đổi nhỏ đều build lại toàn bộ. Với quy mô của Z-EDU, vài phút CI rẻ hơn nhiều so với vài giờ gỡ lỗi lệch phiên bản. Đây là đánh đổi có điều kiện — nếu bạn có 40 service và 8 đội, câu trả lời sẽ khác. Hãy chọn theo quy mô thật của mình, đừng chọn theo bài blog của công ty có 5.000 kỹ sư.

8. Checklist trước khi bật auto scale

Nếu còn một dòng nào chưa tick, auto scale sẽ làm tình hình xấu đi chứ không tốt lên.

Kết luận

Auto scale là một công cụ vận hành hữu ích, và chúng tôi vẫn dùng nó. Nhưng nó là bước cuối, không phải bước đầu. Nó trả lời câu hỏi "cùng một lượng việc, làm sao xử lý nhiều request hơn" — nó không trả lời câu hỏi "vì sao mỗi request lại phải làm nhiều việc đến thế".

Trật tự đúng: bỏ việc thừa ra khỏi đường request (cache ở biên, hàng đợi cho việc phụ), đẩy state ra ngoài tiến trình, đo đúng chỗ nghẽn, rồi mới nhân bản. Làm theo thứ tự đó, bạn thường phát hiện mình cần ít instance hơn mình tưởng. Làm ngược lại, bạn sẽ có hoá đơn hạ tầng lớn hơn và một hệ thống chậm y như cũ — chỉ khác là giờ nó chậm ở nhiều nơi cùng lúc.

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