Có lần mình ngồi trực cái API gateway của một dự án nhỏ. Dashboard báo p99 nhảy lên 32 giây, mà CPU thấp, RAM thừa, không có gì nổ. Mở log mới thấy cảnh quen thuộc: một request đi qua 4 service, mỗi service tự đặt timeout 8 giây cho cái nó gọi. Cộng lại đúng 32 giây. Khách bỏ đi từ giây thứ 10, còn backend vẫn cày tới giây 32 mới chịu ghi log lỗi.

Vấn đề không nằm ở timeout. Nằm ở chỗ mỗi tầng tự đặt timeout riêng mà không ai biết ngân sách thời gian của cả chuỗi còn lại bao nhiêu.

Timeout rời rạc không phải deadline

setTimeout với một hằng số ở mỗi service nhìn có vẻ an toàn, nhưng thiếu hai thứ.

Thứ nhất, nó không cộng dồn. Bốn hop, mỗi hop 8 giây, tổng xấu nhất là 32 giây chứ không phải 8.

Thứ hai, và cái này mới đau: tầng dưới không biết tầng trên đã bỏ đi. Client ngắt kết nối ở giây 10, service A1 vẫn gọi A2, A2 vẫn query database, vẫn ghi hoá đơn, vẫn publish event. Mình từng thấy một job gửi email chạy xong cho một request mà người dùng đã không còn ở đó nữa.

Deadline khác timeout ở chỗ nó là một mốc tuyệt đối, không phải một khoảng thời gian. Mốc đó sinh ra ở ngoài cùng — gateway hoặc service khách gọi trực tiếp — rồi truyền xuống cả chuỗi. Mọi tầng chỉ còn trả lời một câu: tính tới lúc này, mình còn bao nhiêu mili giây?

Truyền deadline xuống bằng header

Cách mình dùng là một header duy nhất, mang epoch mili giây:

import type { NextFunction, Request, Response } from "express";

const DEFAULT_BUDGET_MS = 10_000;

export type DeadlineContext = {
  requestId: string;
  deadline: number;      // mốc hết hạn tuyệt đối, epoch ms
  signal: AbortSignal;   // huỷ khi hết hạn hoặc khi khách ngắt kết nối
};

export function deadlineMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const raw = Number(req.header("x-request-deadline"));
  const incoming = Number.isFinite(raw) && raw > 0 ? raw : Infinity;
  const deadline = Math.min(incoming, Date.now() + DEFAULT_BUDGET_MS);

  const remaining = deadline - Date.now();
  if (remaining <= 0) {
    res.status(504).json({ error: "deadline_exceeded" });
    return;
  }

  // khách ngắt kết nối cũng là lý do chính đáng để dừng mọi việc
  const clientGone = new AbortController();
  res.on("close", () => clientGone.abort(new Error("client_disconnected")));

  req.ctx = {
    requestId: req.header("x-request-id") ?? crypto.randomUUID(),
    deadline,
    signal: AbortSignal.any([clientGone.signal, AbortSignal.timeout(remaining)]),
  };

  next();
}

Điểm cần để ý: middleware không cộng dồn ngân sách. Nó lấy mốc nhỏ hơn giữa mốc nhận được và ngân sách mặc định của chính nó. Tầng nào cũng có trần riêng, nhưng không tầng nào được phép kéo dài hơn mốc tầng trên đã ấn định.

Mỗi hop phải tính lại phần còn lại

Đây là chỗ dễ sai. Nếu mỗi service lại tạo một AbortSignal.timeout với hằng số 8 giây của riêng nó thì deadline vừa truyền xuống đã mất tác dụng — chuỗi lại cộng dồn như cũ.

Helper gọi service khác nên tự tính phần còn lại từ ctx.deadline:

export class DeadlineExceeded extends Error {}

export async function callService<T>(
  ctx: DeadlineContext,
  url: string,
  init: RequestInit = {},
): Promise<T> {
  const remaining = ctx.deadline - Date.now();
  if (remaining <= 0) throw new DeadlineExceeded(`${url}: hết ngân sách`);

  const res = await fetch(url, {
    ...init,
    signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(remaining)]),
    headers: {
      ...init.headers,
      "content-type": "application/json",
      "x-request-id": ctx.requestId,
      "x-request-deadline": String(ctx.deadline),
    },
  });

  if (!res.ok) throw new Error(`${url} trả về ${res.status}`);
  return (await res.json()) as T;
}

Hai signal gộp bằng AbortSignal.any: cái nào nổ trước thì dừng trước. Khách ngắt kết nối cũng dừng, hết deadline cũng dừng — không cần phân biệt, miễn là không còn việc vô ích.

Ba cái bẫy mình từng sập

Retry ăn hết ngân sách. Retry 3 lần, mỗi lần tối đa 8 giây là tệ nhất 24 giây cho một request mà khách chỉ chờ 10 giây. Mình giờ chặn trước khi thử lại:

const MIN_ATTEMPT_MS = 250;

export async function callWithBudget<T>(
  ctx: DeadlineContext,
  url: string,
  attempts = 3,
): Promise<T> {
  let lastError: unknown;
  for (let i = 0; i < attempts; i++) {
    if (ctx.deadline - Date.now() < MIN_ATTEMPT_MS) break; // thử nữa cũng vô nghĩa
    try {
      return await callService<T>(ctx, url);
    } catch (err) {
      if (err instanceof DeadlineExceeded) break; // không phải lỗi mạng, đừng thử lại
      lastError = err;
    }
  }
  throw lastError instanceof Error ? lastError : new DeadlineExceeded(url);
}

Đồng hồ giữa các máy lệch nhau. Gửi epoch mili giây nghĩa là mọi máy phải đồng bộ NTP. Mình từng gặp một máy trong cụm lệch 3 giây, thế là deadline nhận vào đã ở quá khứ và mọi request bị trả 504 sạch trơn. Nếu không chắc chuyện đồng hồ, dùng phương án hai: gửi x-request-timeout-ms là khoảng tương đối, mỗi hop tự trừ đi thời gian nó đã tiêu. Đổi lại là sai số cộng dồn, nhưng an toàn hơn một con số 504 đồng loạt.

Database không nghe signal. AbortSignal dừng được fetch, không dừng được câu query đang chạy trong Postgres — query 20 giây vẫn chạy hết 20 giây. Mình chữa bằng cách đặt trần phía database, ngay trong transaction của request:

await pool.query("SET LOCAL statement_timeout = $1", [remaining]);

Thêm một nhịp kiểm tra ctx.signal.aborted trước khi ghi dữ liệu hoặc commit. Không hoàn hảo, nhưng chặn được phần lớn trường hợp ghi xong mới biết chẳng ai cần kết quả.

Trả lỗi sớm, đừng để khách tự bỏ

Sau lần đó mình chốt một nguyên tắc: biết chắc không kịp thì trả 504 kèm x-request-id ngay, đừng cố. Client nhận lỗi rõ ràng trong 2 giây vẫn hơn treo 30 giây rồi tự ngắt.

Đó cũng là lý do mấy bài graceful shutdown và backpressure nằm cùng một chủ đề với bài này: đều là chuyện hệ thống từ chối việc mà nó biết mình không làm xong. Còn nếu chuỗi của bạn đã có circuit breaker thì deadline là bạn đồng hành tự nhiên — circuit breaker cắt khi service chết, deadline cắt khi service còn sống nhưng chậm.

Hệ thống của bạn đang đặt timeout rải rác trong từng service, hay có một mốc chung ở ngoài cùng? Có hop nào trong chuỗi đang không nhận được deadline không?

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
Deadline Mốc thời điểm tuyệt đối mà request phải xong, khác timeout là khoảng thời gian
Timeout budget Tổng ngân sách thời gian của cả chuỗi, chia dần xuống từng hop
AbortSignal Cơ chế chuẩn của web/Node để báo huỷ một tác vụ bất đồng bộ
AbortSignal.any Gộp nhiều signal: cái nào abort trước thì kết quả abort trước
Hop Một chặng gọi từ service này sang service khác trong chuỗi
Clock skew Đồng hồ các máy lệch nhau, làm mốc tuyệt đối trở nên sai
statement_timeout Biến của Postgres, tự huỷ câu query chạy quá lâu
504 Gateway Timeout Mã HTTP trả về khi gateway không kịp nhận phản hồi từ tầng dưới

Xem các bài viết khác