Z-SOFT
VẬN HÀNH · ĐỘ SẴN SÀNG

Scale và deploy mà không mất request

Gián đoạn hiếm khi xảy ra vì thiếu máy chủ. Nó thường xuất hiện đúng lúc bạn thêm máy, bớt máy hoặc deploy phiên bản mới. Bài viết này trả lời hai câu hỏi: cần nhìn chỉ số nào để biết hệ thống sắp quá tải, và scale/deploy thế nào để người dùng không gặp lỗi. Từ readiness probe, warm-up, graceful shutdown đến rolling update và cạm bẫy dao động của auto scale.

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

Hầu hết sự cố gián đoạn mà chúng tôi từng gỡ không xảy ra vì thiếu máy chủ. Chúng thường xảy ra đúng lúc hệ thống thêm hoặc bớt máy chủ. Một instance vừa khởi động đã bị load balancer gửi request vào trong khi connection pool chưa mở xong. Một instance cũ nhận tín hiệu dừng khi vẫn còn vài chục request đang xử lý và một lô dữ liệu chưa kịp ghi xuống database. Cả hai tình huống đó có thể xảy ra dù CPU vẫn thấp và số replica vẫn đúng như cấu hình.

Nói cách khác: hạ tầng có thể vẫn còn công suất, nhưng người dùng vẫn gặp lỗi 502. Bài viết trước — Chịu tải cho SaaS: vì sao auto scale không cứu được thiết kế sai — trả lời câu hỏi scale cái gì trước. Bài này trả lời hai câu hỏi còn lại, và chúng khó hơn: đo gì để biết hệ thống sắp quá tải, và scale/deploy thế nào để không mất request.

1. Gián đoạn sinh ra từ chính lúc bạn thay đổi

Một hệ thống đang đứng yên thì tương đối dễ giữ ổn định. Vấn đề là hệ thống thật không bao giờ đứng yên: bạn deploy phiên bản mới vài lần một tuần, auto scale thêm bớt replica theo tải, node được cập nhật kernel rồi bị drain, container bị chuyển sang máy khác. Mỗi lần như vậy là một lần vòng đời tiến trình bị cắt ngang, và đó là lúc request dễ bị mất nhất.

Có hai lỗi cơ bản, và chúng đối xứng nhau.

Lỗi thứ nhất — nhận traffic quá sớm. Tiến trình Node vừa mở cổng 3000 là orchestrator có thể coi như xong và bắt đầu định tuyến request. Nhưng mở cổng không có nghĩa là sẵn sàng phục vụ: connection pool của Prisma có thể chưa mở, Redis chưa kết nối, cache trong process còn rỗng, V8 chưa kịp warm-up các đoạn code nóng. Request đầu tiên đi vào instance đó sẽ chậm hơn hẳn, hoặc lỗi ngay vì pool chưa có connection.

Lỗi thứ hai — rút khỏi hệ thống quá muộn. Orchestrator gửi SIGTERM, tiến trình gọi process.exit() ngay, và các request đang xử lý dở biến thành connection reset. Load balancer chưa kịp biết instance đó đã dừng, nên vẫn tiếp tục gửi request mới trong vài giây. Người dùng nhìn thấy lỗi 502.

Sẵn sàng phục vụ không chỉ là chuyện "tiến trình còn sống hay đã chết". Lúc khởi động, instance cần thời gian để chuẩn bị. Lúc tắt, nó cũng cần thời gian để rút khỏi load balancer và xử lý nốt việc đang dở. Bỏ qua một trong hai giai đoạn này, bạn sẽ mất request mỗi lần deploy, chỉ là đôi khi số lỗi nhỏ đến mức bị che trong nhiễu.

2. Đo cái gì thì biết hệ thống sắp đuối

Có thể chia chỉ số thành hai nhóm. Nhóm thứ nhất là chỉ số nguyên nhân: CPU, RAM, băng thông ổ đĩa. Chúng cho biết tài nguyên đang bị dùng như thế nào. Nhóm thứ hai là chỉ số hậu quả: p99 latency, tỉ lệ lỗi, độ sâu hàng đợi. Chúng cho biết người dùng và hệ thống phía sau đang chịu tác động ra sao.

Sai lầm phổ biến là chỉ cảnh báo theo nhóm thứ nhất. Với một API SaaS điển hình, phần lớn thời gian request là chờ Postgres hoặc Redis. CPU có thể nằm dưới 20% trong lúc p99 lên tới 8 giây, chỉ vì các request đang hàng đợi chờ một connection pool đã cạn. Tiến trình không bận tính toán; nó đang chờ. Mà chờ thì không làm CPU tăng.

Hai khung tư duy phổ biến trong ngành giúp không bỏ sót:

Điểm mấu chốt nằm ở chữ Saturation. Utilization cho biết tài nguyên đang được dùng bao nhiêu. Saturation cho biết có bao nhiêu việc đang phải hàng đợi chờ tài nguyên đó. Một connection pool 100% utilization nhưng không có request nào chờ thì vẫn ổn: pool đang được dùng hết công suất, đúng như thiết kế. Cũng pool đó, 100% utilization và có 60 request đang chờ lấy connection, là dấu hiệu hệ thống sắp quá tải. Con số 60 kia mới là thứ cần cảnh báo.

Trong Z-EDU, chúng tôi có một chỉ số saturation rất trực tiếp và rất rẻ. Tiến độ học được ghi theo cơ chế write-behind: ping của trình phát video chỉ chạm Redis, một worker dồn lô xuống Postgres theo chu kỳ. Endpoint /health trả về pendingProgress — số bản ghi tiến độ đang chờ ghi xuống database. Bình thường con số này dao động quanh 0, vì worker liên tục xử lý hết phần tồn. 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.

// health.controller.ts — health check phải nói được điều gì đó có ích
@Get('/health')
async health() {
  const [pendingProgress, auditWaiting, auditFailed] = await Promise.all([
    this.progressBuffer.size(),            // số key Redis đang chờ flush
    this.auditQueue.getWaitingCount(),     // zedu-audit
    this.auditQueue.getFailedCount(),
  ]);

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

Điều đáng chú ý là khi worker chết, CPU không tăng; nhiều khi còn giảm. Số request/giây không đổi. Tỉ lệ lỗi HTTP vẫn bằng 0, vì API vẫn nhận ping và trả 200. Dashboard hạ tầng nhìn vẫn xanh, trong khi dữ liệu lặng lẽ đọng lại trong Redis. Với kiến trúc bất đồng bộ, bạn phải cảnh báo trên chính chỉ số tồn đọng, không chỉ trên CPU hay lỗi HTTP.

Chỉ số nên cảnh báo (hậu quả / saturation) Vì sao nó báo trước Chỉ số dễ gây hiểu nhầm nếu dùng một mình
Số request đang chờ lấy connection từ pool Hàng đợi xuất hiện trước khi latency tăng mạnh. Đây là cảnh báo sớm thật sự. CPU — I/O-bound thì CPU rảnh trong lúc mọi người đang chờ.
Queue depth tăng monotonic Tốc độ nạp > tốc độ xử lý. Đây là định nghĩa toán học của "sắp vỡ". Throughput (req/s) — vẫn cao khi hệ thống trả lỗi rất nhanh.
pendingProgress tăng lên và đứng yên Nói thẳng rằng worker không tiêu thụ được. Không có cách diễn giải khác. Uptime cổng HTTP — API vẫn 200 khi worker đã chết từ lâu.
p99 latency theo từng route Bắt được phần đuô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.
Tỉ lệ readiness thất bại theo instance Phát hiện instance mới không warm-up nổi trước khi nó kéo p99 lên. Số replica đang chạy — đếm được container không có nghĩa là chúng phục vụ được.
RAM của container Chỉ hữu ích nếu nhìn xu hướng: dốc lên đều = rò rỉ. RAM tức thời — thường phẳng lì cho tới đúng lúc OOM rồi mới dựng đứng.

3. Vì sao p99 mới đáng lo, không phải trung bình

Giả sử một route có latency trung bình 120ms. Nghe rất ổn. Nhưng trung bình là một phép cộng rồi chia — nó không phân biệt được "mọi request đều 120ms" với "99 request 60ms và 1 request 6 giây". Hai hệ thống đó có cùng con số trên dashboard và hoàn toàn khác nhau trong đời thật.

Phân vị nói thật hơn. p99 = 6 giây nghĩa là trong 100 request sẽ có khoảng 1 request mất từ 6 giây trở lên. Nếu bạn nghĩ "chỉ 1%, vẫn chấp nhận được", hãy nhân nó lên theo cách một trang thật hoạt động.

Một trang trong ứng dụng thật hiếm khi gọi đúng một API. Trang chi tiết khoá học gọi thông tin khoá, danh sách bài giảng, tiến độ của học viên, quyền của người dùng — cứ cho là 8 lệnh gọi. Xác suất cả 8 lệnh đều nằm ngoài phần đuôi là 0,99 mũ 8, tức khoảng 92%. Nghĩa là khoảng 8% số lần tải trang sẽ dính ít nhất một request chậm. Con số "1% chậm" ở tầng request vừa biến thành "8% người dùng thấy trang chậm" ở tầng trải nghiệm. Và một trang chỉ nhanh bằng lệnh gọi chậm nhất của nó.

Phần đuôi không phải là ngoại lệ hiếm gặp. Với một giao diện gọi nhiều API song song, phần đuôi chính là trải nghiệm phổ biến. Đây là lý do mọi SLO nghiêm túc đều viết theo phân vị, không bao giờ theo trung bình.

Điều này còn quan trọng hơn khi scale. Phần đuôi là nơi phản ứng đầu tiên khi hệ thống bắt đầu bão hoà. Trung bình có thể vẫn đẹp trong lúc p99 đã tăng gấp ba. Nếu chỉ nhìn trung bình, bạn sẽ luôn biết tin muộn, thường là sau khi khách hàng báo lỗi.

4. Health check: liveness không phải là readiness

Đây là chỗ nhiều hệ thống tự gây lỗi cho chính mình, nên đáng nói kỹ.

Liveness trả lời câu hỏi: tiến trình này có cần bị giết và khởi động lại không? Nó chỉ nên kiểm tra những thứ restart có thể sửa được, ví dụ event loop còn quay hay tiến trình có bị deadlock không.

Readiness trả lời câu hỏi khác: instance này có nên nhận request ngay lúc này không? Nó được phép kiểm tra dependency. Trả lời "chưa sẵn sàng" là chuyện bình thường trong lúc warm-up hoặc đang shutdown.

Lỗi kinh điển là để liveness probe gọi xuống database. Nghe có vẻ hợp lý: "API mà không nói chuyện được với DB thì còn sống làm gì?". Nhưng hậu quả rất tệ. Postgres chớp nháy 3 giây vì failover, liveness của tất cả instance cùng thất bại, orchestrator giết sạch chúng rồi khởi động lại đồng loạt. Một loạt instance mới cùng lúc mở connection tới database vừa mới hồi phục, khiến database gục lần nữa. Một sự cố 3 giây có thể biến thành 15 phút chỉ vì health check đặt sai chỗ.

Nguyên tắc: liveness chỉ kiểm tra thứ mà restart sửa được. DB gặp sự cố thì restart API không sửa được gì; nó chỉ làm mọi thứ tệ hơn. Dependency ngoài thuộc về readiness, và readiness thì không giết tiến trình, nó chỉ rút tiến trình khỏi vòng nhận traffic.
Tiêu chíLivenessReadiness
Câu hỏi Có cần giết và restart không? Có nên gửi request vào không?
Hậu quả khi thất bại Container bị giết Bị rút khỏi load balancer, vẫn sống
Được kiểm tra DB/Redis? Không. Restart không sửa được DB. Có — đó chính là việc của nó.
Thất bại lúc khởi động Bất thường Bình thường — đang warm-up
Thất bại lúc shutdown Bất thường Bình thường — đang drain, và phải là bước đầu tiên
Rủi ro nếu viết sai Sự cố nhỏ leo thang thành sập toàn hệ thống Instance nhận traffic khi chưa sẵn sàng, p99 vọt lên
// health.controller.ts — hai endpoint, hai mục đích, đừng gộp làm một
@Controller()
export class HealthController {
  private ready = false;   // bật khi warm-up xong, tắt khi bắt đầu shutdown

  markReady()    { this.ready = true; }
  markDraining() { this.ready = false; }

  // LIVENESS: chỉ chứng minh event loop còn quay. TUYỆT ĐỐI không chạm DB/Redis.
  @Get('/livez')
  livez() {
    return { status: 'ok', uptime: process.uptime() };
  }

  // READINESS: được phép chạm dependency. Thất bại => rút khỏi LB, KHÔNG bị giết.
  @Get('/readyz')
  async readyz(@Res({ passthrough: true }) res: Response) {
    if (!this.ready) {
      res.status(503);
      return { status: 'draining_or_warming' };
    }
    try {
      await this.prisma.$queryRaw`SELECT 1`;   // pool đã mở chưa?
      await this.redis.ping();
      return { status: 'ok' };
    } catch (err) {
      res.status(503);
      return { status: 'deps_unavailable' };
    }
  }
}

Ở tầng cấu hình, có hai thứ cần chú ý: khoảng thời gian ân hạn lúc khởi động để không tính các lần thất bại khi tiến trình còn đang boot, và số lần thất bại liên tiếp trước khi hành động để không phản ứng quá mức với một lần chớp nháy.

# Docker Swarm — HEALTHCHECK quyết định container có được nhận traffic không.
# Trỏ vào READINESS, không phải liveness, và cho start_period đủ dài để warm-up.
healthcheck:
  test: ["CMD", "node", "-e", "fetch('http://localhost:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
  interval: 5s        # hỏi mỗi 5s
  timeout: 3s         # chậm hơn 3s coi như hỏng
  retries: 3          # 3 lần liên tiếp mới kết luận — chống chớp nháy
  start_period: 30s   # ân hạn lúc boot: thất bại trong 30s đầu KHÔNG bị tính

5. Scale ra mà không gián đoạn: warm-up và điều kiện stateless

Thêm một instance nghe có vẻ là thao tác rất an toàn. Thực tế nó có hai cái bẫy.

Bẫy thứ nhất: instance còn lạnh. Một tiến trình Node vừa start gần như chưa có gì: connection pool của Prisma còn rỗng, Redis chưa bắt tay xong, cache trong process chưa có dữ liệu, V8 chưa JIT các đoạn code nóng. Nếu load balancer chia traffic ngay từ giây đầu tiên, những request đi vào instance mới sẽ chậm hơn đáng kể. Nghịch lý là bạn scale ra p99 đang xấu, nhưng ngay sau khi scale, p99 có thể còn xấu hơn trong vài giây đầu. Nhiều đội thấy vậy rồi kết luận nhầm rằng "scale không có tác dụng".

Cách xử lý là để readiness làm cổng chắn: instance chỉ báo sẵn sàng sau khi đã tự warm-up. Đừng dựa vào một khoảng sleep đoán mò; hãy dựa vào việc thật sự đã hoàn tất.

// main.ts — chỉ báo READY sau khi đã thật sự sẵn sàng phục vụ
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.setGlobalPrefix('api/v1');

await app.listen(3000);          // cổng đã mở, nhưng /readyz vẫn trả 503

// Warm-up: làm đúng những việc mà request đầu tiên sẽ phải làm.
const health = app.get(HealthController);
await warmUp(app);
health.markReady();              // từ giây này /readyz mới trả 200
logger.log('ready to serve traffic');

async function warmUp(app: INestApplication): Promise<void> {
  const prisma = app.get(PrismaService);
  const redis  = app.get(RedisService);

  // 1. Ép pool mở sẵn connection, thay vì để request đầu tiên phải chờ bắt tay TCP + TLS.
  await Promise.all(
    Array.from({ length: 5 }, () => prisma.$queryRaw`SELECT 1`),
  );
  // 2. Bắt tay Redis.
  await redis.ping();
  // 3. Nạp trước vài khoá cấu hình mà gần như mọi request đều đọc.
  await app.get(SettingsService).preload();
}

Bẫy thứ hai, nguy hiểm hơn: tiến trình không thật sự stateless. Scale ngang ngầm giả định rằng mọi instance đều tương đương nhau. Request đi vào instance nào cũng phải cho cùng kết quả. Điều này chỉ đúng khi tiến trình không giữ state quan trọng trong RAM của chính nó.

Ví dụ rõ nhất là rate-limit. Nếu bộ đếm theo IP nằm trong RAM tiến trình, chẳng hạn một Map<string, number>, thì với 3 replica, mỗi IP có thể 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, vì nó phụ thuộc vào việc load balancer gửi request vào instance nào. Thêm instance lúc này không chỉ là scale, mà còn âm thầm nới lỏng một biện pháp bảo vệ. Rate-limit của Z-EDU đếm theo IP qua Redis, nên hạn mức là một con số toàn cục, không đổi dù có bao nhiêu replica.

Cùng nguyên tắc này áp dụng cho các phần còn lại: dữ liệu nghiệp vụ nằm ở Postgres, file nằm ở RustFS (S3-compatible, trình duyệt PUT thẳng qua presigned URL nên không cần file tạm trên đĩa instance), state tạm và dữ liệu tốc độ cao nằm ở Redis. Bài kiểm tra rất đơn giản: nếu giết ngẫu nhiên một instance ngay lúc đang có tải, có ai mất dữ liệu hoặc bị lỗi không? Nếu câu trả lời là "một số người bị đăng xuất", "upload dở dang bị hỏng", hoặc "bộ đếm bị sai", thì hệ thống chưa stateless đủ để auto scale an toàn.

6. Scale vào và deploy lại mà không mất request

Đây là nửa khó hơn, và cũng là nửa hay bị bỏ quên hơn. Bớt một replica, rolling update, node bị drain: cả ba đều dẫn tới cùng một sự kiện. Tiến trình nhận SIGTERM và chỉ có một khoảng thời gian hữu hạn để dừng một cách gọn gàng.

Trình tự đúng có bốn bước, và thứ tự là toàn bộ vấn đề:

  1. Đánh dấu readiness = false ngay lập tức. Load balancer cần vài chu kỳ health check để nhận ra và ngừng gửi request mới. Trong khoảng đó, tiến trình vẫn phải phục vụ bình thường. Nó chưa được phép từ chối request; nó chỉ đang báo rằng mình chuẩn bị rút khỏi vòng nhận traffic.
  2. Chờ hết request đang chạy (drain). Ngừng nhận connection mới, để những request dở dang chạy nốt. Có deadline: quá hạn thì cắt, nhưng deadline phải rộng hơn thời gian xử lý của request chậm nhất.
  3. Đóng consumer của hàng đợi và chờ job đang chạy. Xem bên dưới — đây là bước hay bị bỏ nhất.
  4. Đóng kết nối DB/Redis, rồi mới thoát. Đóng pool khi còn request đang chạy nghĩa là biến chúng thành lỗi 500 một cách chủ động.

Bước 1 là bước rất hay bị bỏ, và nó là nguyên nhân của nhiều lỗi 502 lúc deploy. Giữa thời điểm tiến trình đóng listener và thời điểm load balancer biết điều đó luôn có một khoảng trễ, thường đúng bằng vài chu kỳ health check. Request tới trong khoảng trễ này có thể bị gửi vào một instance đang dừng. Vì vậy phải báo "tôi không sẵn sàng" trước, chờ load balancer cập nhật, rồi mới đóng listener.

// main.ts + shutdown.service.ts — graceful shutdown ĐÚNG THỨ TỰ
app.enableShutdownHooks();       // Nest gọi onModuleDestroy/onApplicationShutdown khi SIGTERM

@Injectable()
export class ShutdownService implements OnApplicationShutdown {
  constructor(
    private readonly health: HealthController,
    private readonly progressFlusher: ProgressFlusher,
    @InjectQueue('zedu-audit') private readonly auditQueue: Queue,
  ) {}

  async onApplicationShutdown(signal?: string): Promise<void> {
    this.logger.log(`${signal} nhận được, bắt đầu drain`);

    // (a) Rút khỏi load balancer TRƯỚC. /readyz trả 503 từ giây này.
    this.health.markDraining();

    // (b) Chờ LB nhận ra: interval * retries + biên an toàn.
    //     Trong lúc chờ, request đang bay vào VẪN được phục vụ bình thường.
    await sleep(15_000);

    // (c) Ngừng nhận connection mới, chờ request đang chạy xong (Nest tự làm khi close()).
    //     (d) Flush nốt lô write-behind còn trong Redis buffer — đừng bỏ dữ liệu lại.
    await this.progressFlusher.flushNow();

    await this.auditQueue.close();
    this.logger.log('drain xong, thoát');
  }
}

Với worker hàng đợi, quy tắc còn nghiêm hơn. Nếu một job đang chạy bị giết giữa chừng, lô ghi đó có thể mất hoặc chỉ ghi được một nửa. BullMQ đã có cơ chế phù hợp: worker.close() ngừng nhận job mới và chờ job đang chạy hoàn tất. Nếu quá stop-grace-period mà job chưa xong, job vẫn còn trong hàng đợi và sẽ được instance khác nhặt lại sau khi lock hết hạn, với điều kiện handler của bạn idempotent. BullMQ đảm bảo at-least-once, không phải exactly-once. Vì vậy quy tắc gộp tiến độ của Z-EDU nằm trong SQL (INSERT ... ON CONFLICT với GREATEST): một lô chạy lại lần hai cũng không làm tiến độ bị tụt.

// worker.shutdown.ts — worker phải chờ job đang chạy, không được giết ngang
@Injectable()
export class WorkerShutdown implements OnApplicationShutdown {
  constructor(private readonly workers: Worker[]) {}

  async onApplicationShutdown(): Promise<void> {
    // close(false) = ngừng nhận job MỚI, CHỜ job đang chạy xong.
    // close(true)  = ép dừng ngay -> job dở dang trả về hàng đợi, chạy lại sau.
    await Promise.all(this.workers.map((w) => w.close(/* force */ false)));
  }
}

// Điều kiện tiên quyết để (2) an toàn: handler idempotent.
// Job chạy lại lần thứ hai phải cho đúng kết quả như lần đầu.

Cuối cùng là tầng orchestrator. Toàn bộ nỗ lực trong code sẽ vô nghĩa nếu Swarm giết container sau 10 giây trong khi ứng dụng cần 30 giây để drain. Có ba nhóm cấu hình quyết định chuyện này.

# docker-compose.yml (Swarm) — rolling update không mất request
services:
  api:
    image: registry.example/zedu:${TAG}
    environment:
      API_ENABLED: "1"
      WORKER_ENABLED: "0"
    healthcheck:
      test: ["CMD", "node", "/app/healthcheck.js"]   # trỏ vào /readyz
      interval: 5s
      retries: 3
      start_period: 30s
    stop_signal: SIGTERM
    # PHẢI > thời gian drain trong code (15s chờ LB + request chậm nhất).
    # Hết hạn này Swarm gửi SIGKILL, nên phải đặt rộng hơn thời gian drain thật.
    stop_grace_period: 45s
    deploy:
      replicas: 3
      update_config:
        order: start-first     # dựng instance MỚI, chờ nó healthy, RỒI mới hạ instance cũ
        parallelism: 1         # mỗi lần một replica: luôn còn đủ công suất phục vụ
        delay: 10s             # nghỉ giữa hai bước, cho hệ thống ổn định lại
        failure_action: rollback
        monitor: 60s           # theo dõi 60s sau mỗi bước; hỏng thì tự rollback
      rollback_config:
        order: start-first
        parallelism: 1

  worker:
    image: registry.example/zedu:${TAG}    # CHÍNH image đó, chỉ khác vai trò
    environment:
      API_ENABLED: "0"
      WORKER_ENABLED: "1"
    # Worker cần rộng tay hơn API: phải chờ job đang chạy hoàn tất.
    stop_grace_period: 90s
    deploy:
      replicas: 2
      update_config:
        order: stop-first      # worker KHÔNG nhận traffic từ LB -> không cần chồng lấn
        parallelism: 1

Có hai chi tiết đáng dừng lại. order: start-first cho API nghĩa là instance mới được dựng và phải healthy trước khi instance cũ bị hạ. Nhờ vậy công suất phục vụ không tụt dưới mức thiết kế trong lúc deploy. Cái giá là trong vài giây, hai phiên bản code chạy song song. Điều đó buộc cả hai phiên bản phải tương thích với cùng một schema database. Vì vậy migration phải đi theo lối cộng dồn: thêm cột trước, đọc/ghi cả đường cũ lẫn đường mới, rồi xoá cột cũ ở lần deploy sau.

Ngược lại, worker dùng order: stop-first: nó không nhận traffic từ load balancer, nên chẳng có lý do gì phải chồng lấn; hạ cũ trước, dựng mới sau, đơn giản hơn và tiết kiệm tài nguyên hơn. Và stop_grace_period của worker phải rộng hơn API, vì một job có thể chạy lâu hơn một request rất nhiều.

7. Tự vệ khi quá tải: load shedding và fail-open

Auto scale không diễn ra tức thời. Từ lúc chỉ số vượt ngưỡng đến lúc instance mới sẵn sàng phục vụ có thể mất hàng chục giây, đôi khi hàng phút. Trong khoảng trống đó, hệ thống phải tự bảo vệ bằng cách chủ động từ chối bớt việc.

Đó là load shedding. Khi hệ thống đã quá tải, cố phục vụ tất cả thường khiến tất cả cùng chậm. Request hàng đợi , client timeout, client retry, hàng đợi lại dài thêm. Từ chối sớm 5% request bằng 429 có thể giữ cho 95% còn lại chạy nhanh; cố ôm 100% đôi khi làm cả 100% cùng thất bại. Trả lỗi nhanh và rõ ràng thường tốt hơn treo lâu rồi timeout.

Rate-limit là hình thức load shedding có kế hoạch. Z-EDU đếm rate-limit theo IP qua Redis — bắt buộc, vì như đã nói ở mục 5, bộ đếm trong RAM sẽ vỡ ngay khi có replica thứ hai. Nhưng điều đó tạo ra một câu hỏi mới, và câu hỏi này không có đáp án đúng phổ quát: Redis chết thì làm gì?

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, và 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 fail-closed, một thành phần phụ trợ có thể biến thành single point of failure của toàn hệ thống: học viên không vào học được chỉ vì bộ đếm không đếm được. Điều này nhất quán với phần còn lại của hệ thống: Redis gặp sự cố thì cache miss xuống thẳng DB (chậm hơn, nhưng vẫn đúng), write-behind ghi thẳng DB.

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 — đăng nhập và quên mật khẩu (rate-limit chính là thứ chặn brute-force), gửi OTP/SMS/email (mỗi request tốn tiền thật), gọi API bên thứ ba có hạn ngạch. Ở những chỗ đó, từ chối an toàn hơn nhiều so với mở cửa.

Đánh đổi cần nói thẳng, vì fail-open không miễn phí: trong khoảng thời gian Redis gặp sự cố, hệ thống gần như không có rate-limit. Vì vậy mỗi lần chuyển sang fail-open phải phát metric và cảnh báo. Fail-open âm thầm rất nguy hiểm: bạn tưởng mình có một lớp bảo vệ, trong khi nó có thể đã tắt từ nhiều ngày trước.

8. Auto scale: scale theo chỉ số nào, và cạm bẫy dao động

Bây giờ mới tới auto scale, và nó là bước cuối chứ không phải bước đầu. Ba nguyên tắc.

Một: đừng scale theo CPU với hệ thống I/O-bound. Như đã nói ở mục 2, một API chờ DB có thể có CPU dưới 20% trong lúc p99 là tám giây. Auto scale theo 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 điện thoại reo. Hãy scale theo chỉ số phản ánh công việc tồn đọng: số request đang chờ, độ sâu hàng đợi, p99 latency của route quan trọng.

Hai: scale up nhanh, scale down chậm. Hai chiều này bất đối xứng về chi phí sai lầm. Thêm nhầm một instance tốn tiền; bớt nhầm một instance lúc tải sắp tăng thì mất request. Vì thế: ngưỡng scale up nhạy, thời gian quan sát ngắn (một hai phút); ngưỡng scale down khắt khe, thời gian quan sát dài (mười phút hoặc hơn), và mỗi lần chỉ bớt một replica.

Ba: cẩn thận với dao động (flapping). Đây là vòng lặp rất dễ gặp. Tải tăng → scale up → instance mới còn lạnh, chưa warm-up → p99 tạm thời xấu hơn → autoscaler tưởng vẫn thiếu → scale tiếp. Hoặc chiều ngược lại: scale down → tải dồn lên số instance còn lại → chỉ số vượt ngưỡng → scale up → tải giãn ra → chỉ số tụt → scale down. Hệ thống cứ thêm rồi bớt replica liên tục, và mỗi chu kỳ lại kéo theo rủi ro drain/warm-up.

Có ba cách nên dùng cùng lúc để tránh dao động: cooldown sau mỗi lần scale, đủ dài hơn thời gian warm-up; vùng đệm giữa ngưỡng scale up và scale down, ví dụ scale up khi p99 > 800ms nhưng chỉ scale down khi p99 < 300ms; và cửa sổ quan sát đủ dài để một đợt tải đột biến 30 giây không tự kích hoạt scale.

Một điểm thuận lợi trong thiết kế của Z-EDU: worker BullMQ chạy chung tiến trình API nhưng tắt được bằng WORKER_ENABLED=0, và toàn bộ monorepo build ra một image duy nhất. Muốn scale worker độc lập với API thì chỉ cần dựng thêm một service dùng chính image đó, khác biến môi trường — không sửa code, không build lại. Điều này quan trọng vì hai vai trò bão hoà theo hai chỉ số hoàn toàn khác nhau: API bão hoà theo p99 và số connection đang chờ, worker bão hoà theo độ sâu hàng đợi và pendingProgress. Ép chúng scale chung một luật là sai cả hai lần.

9. Checklist: trước khi bật auto scale, hệ thống phải có

Nếu còn một dòng chưa tick, auto scale sẽ là cỗ máy sinh sự cố chứ không phải công cụ chống sự cố — vì nó biến việc thêm/bớt instance từ hành động thủ công hiếm hoi thành việc xảy ra tự động, hàng ngày, lúc bạn đang ngủ.

Kết luận

Zero downtime không phải là một tính năng bạn bật lên trong bảng điều khiển hạ tầng. Nó là tổng của một loạt chi tiết nhỏ, tẻ nhạt, mà mỗi chi tiết đều dễ bỏ quên: một endpoint readiness tách khỏi liveness, một hàm warm-up chạy trước khi báo sẵn sàng, mười lăm giây chờ load balancer nhận ra mình đang rút lui, một con số stop_grace_period đặt đúng, một worker.close(false) thay vì process.exit().

Không chi tiết nào trong số đó gây ấn tượng khi đem đi khoe. Nhưng cộng lại, chúng là khác biệt giữa một hệ thống deploy được giữa giờ cao điểm và một hệ thống chỉ dám deploy lúc hai giờ sáng. Và cái thước đo cuối cùng cũng đơn giản thôi: nếu bạn không dám bấm deploy vào lúc đông người dùng nhất, thì bạn chưa có zero downtime — bạn chỉ đang có may mắn.

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