Timeout budget & deadline propagation: cách tránh request treo trong microservice — hoatq.dev

cat blog/.md

Timeout budget & deadline propagation: cách tránh request treo trong microservice

date: tags: backend, microservices, reliability, performance, observability

Hôm nọ team tôi có một sự cố nghe rất buồn cười nhưng đau: một endpoint checkout thi thoảng chậm 40 giây, trong khi Nginx timeout của frontend là 30s. User thấy lỗi 504, refresh, thử lại — và mỗi lần thử lại, backend vẫn tiếp tục xử lý request cũ đến cuối. Có lúc CPU worker chạy 100% chỉ để làm việc cho một loạt user đã bỏ đi từ lâu.

Nguyên nhân không phải code chậm. Nguyên nhân là mỗi service trong chain đặt timeout riêng, không ai nói cho ai biết deadline thật sự là bao lâu. Đây là lỗi thiết kế cực phổ biến, và cách sửa gọi là timeout budget + deadline propagation.

Timeout tĩnh là cái bẫy

Setup điển hình của một microservice B2B:

Client (30s)
  → API Gateway (30s)
    → Order Service (30s)
      → Inventory Service (30s)
        → Warehouse DB (30s query timeout)

Nhìn qua thấy “à mỗi tầng 30 giây, chắc chắn user không phải chờ quá 30 giây”. Sai. Đây là timeout độc lập. Nếu Inventory chờ Warehouse 25 giây rồi Warehouse trả về, Inventory vẫn còn 5 giây nữa mới hit timeout riêng. Nhưng lúc đó client đã bỏ cuộc từ giây thứ 30 rồi.

Vấn đề lớn hơn: khi client đã disconnect, các service phía dưới không biết. Chúng vẫn cày tiếp cho đến khi tự hit timeout, tốn CPU, tốn connection pool, tốn lock trên DB. Trong lúc đó user đã retry, tạo ra request mới, và request mới cũng đi vào cùng chuỗi này. Sau vài phút bạn có 3-4 request “zombie” cho mỗi user impatient — hệ thống thắt cổ chai không phải vì slow, mà vì lãng phí.

Timeout budget là gì

Ý tưởng đơn giản: client là người quyết định deadline, không phải server. Client bảo “tôi chờ tối đa 10 giây”, con số này được truyền xuống từng hop trong chain. Mỗi service khi gọi service tiếp theo phải:

  1. Trừ đi thời gian đã tiêu (cộng thêm một buffer nhỏ cho network và processing)
  2. Truyền phần còn lại xuống service kế tiếp
  3. Nếu ngân sách còn quá ít (dưới ngưỡng khả thi) → fail fast, không gọi nữa
Client budget: 10s
  → Gateway (đã tốn 100ms) → còn 9.9s truyền xuống
    → Order (đã tốn 300ms trong service) → còn 9.6s
      → Inventory (đã tốn 200ms) → còn 9.4s
        → DB query với statement_timeout = 9.4s

Khi ngân sách còn 200ms mà bạn đang chuẩn bị gọi một service thường mất 1s — đừng gọi. Trả lỗi luôn để client có thời gian handle. Kéo dài chỉ tổ để user chờ thêm rồi vẫn nhận 504.

Cách truyền deadline giữa các service

HTTP: dùng header

Không có chuẩn chính thức nhưng có convention từ gRPC / Envoy:

X-Request-Deadline: 1720844400.500        # unix timestamp (giây, float)
# hoặc
X-Timeout-Ms: 9400                        # còn lại bao nhiêu ms
# hoặc
grpc-timeout: 9400m                       # gRPC format (m = ms)

Tôi thường dùng timestamp tuyệt đối vì nó không cần clock sync mà vẫn đúng trong một chain đơn hướng — mỗi service chỉ cần đọc timestamp, so với now() của chính nó là ra thời gian còn lại. Tương đối (ms còn lại) đơn giản hơn nhưng phải cẩn thận vì mỗi khi qua queue, sleep, retry — số ms cũ thành ma.

Ví dụ FastAPI middleware:

from fastapi import Request, HTTPException
import time, asyncio, httpx

DEADLINE_HEADER = "x-request-deadline"

async def deadline_middleware(request: Request, call_next):
    header = request.headers.get(DEADLINE_HEADER)
    if header:
        deadline = float(header)
        remaining = deadline - time.time()
        if remaining <= 0:
            raise HTTPException(504, "Deadline exceeded before processing")
        request.state.deadline = deadline
    else:
        # client không set → dùng default, nhưng vẫn tạo deadline tuyệt đối
        request.state.deadline = time.time() + 10.0
    return await call_next(request)


async def call_downstream(request: Request, url: str, buffer_ms: int = 50):
    remaining = request.state.deadline - time.time()
    if remaining * 1000 < buffer_ms + 100:
        raise HTTPException(504, "Not enough budget to call downstream")

    timeout = httpx.Timeout(remaining - buffer_ms / 1000)
    headers = {DEADLINE_HEADER: str(request.state.deadline)}
    async with httpx.AsyncClient(timeout=timeout) as client:
        return await client.get(url, headers=headers)

Điểm mấu chốt: httpx.Timeout được tính động theo ngân sách còn lại, không phải hằng số. Và deadline được forward xuống service tiếp theo qua header.

gRPC: có sẵn

gRPC deadline là first-class citizen. Khi client set deadline, mọi hop sau đó tự động thấy deadline qua context. Trong Go:

ctx, cancel := context.WithTimeout(ctx, 10 * time.Second)
defer cancel()
resp, err := client.PlaceOrder(ctx, req)

Server đọc ctx.Deadline() để biết còn bao nhiêu thời gian và dừng sớm khi hết. Đây là một trong những lý do gRPC dễ chịu hơn REST cho microservice — deadline propagation không phải tự implement.

Database: statement_timeout động

Postgres cho phép set SET LOCAL statement_timeout cho từng transaction:

async def query_with_budget(session, deadline: float, sql: str, params):
    remaining_ms = int((deadline - time.time()) * 1000) - 50  # buffer 50ms
    if remaining_ms <= 0:
        raise DeadlineExceeded()
    await session.execute(text(f"SET LOCAL statement_timeout = {remaining_ms}"))
    return await session.execute(text(sql), params)

Đây là chốt cuối cùng — nếu query DB không tự dừng khi hết ngân sách, backend sẽ giữ connection pool trong lúc client đã đi mất. Ai từng bị connection pool cạn vào giờ cao điểm sẽ thấm.

Cancellation propagation

Deadline chỉ là một nửa câu chuyện. Nửa còn lại là hủy khi client đã đi. HTTP/1.1 không có tín hiệu chuẩn cho việc này, nhưng framework tốt phát hiện được qua socket close.

FastAPI (Starlette) expose request.is_disconnected():

@router.get("/expensive")
async def expensive_endpoint(request: Request):
    async for chunk in slow_iterator():
        if await request.is_disconnected():
            logger.info("client disconnected, aborting")
            break
        yield chunk

Trong Go, đóng gói bằng context.Context — mọi hàm nhận ctx và tự kiểm tra ctx.Done(). Đây là lý do context là thứ cực kỳ đắt giá trong Go — không có nó, bạn sẽ tự viết cancellation propagation kém hơn.

Với queue-based work (RabbitMQ, SQS): worker không có “client disconnected” signal. Ở đó bạn dùng deadline tuyệt đối trong message payload. Worker pick message lên, so với now(), nếu quá deadline → ack và drop, đừng xử lý.

Anti-pattern hay gặp

1. “Cứ để timeout to cho chắc”. Timeout 60s ở API gateway trong khi user Nginx timeout 30s = 30 giây zombie work sau mỗi lần user bỏ cuộc. Timeout gateway phải ≤ timeout upstream nó nhìn thấy.

2. Fixed retry inside timeout budget. Service A gọi B với retry 3 lần, mỗi lần timeout 5s = có thể tốn 15s. Nếu tổng budget chỉ có 10s → 2 retry cuối là vô nghĩa. Retry phải check deadline trước mỗi lần thử, không phải count-based.

3. Không có buffer. Trừ chính xác remaining_ms không đủ; network jitter, TCP handshake, serialization đều tốn thời gian. Trừ thêm 50-100ms buffer cho mỗi hop, và luôn để lại ít nhất 100ms để trả lỗi sạch thay vì để socket của client timeout.

4. Coi timeout như config tuyệt đối. Không có “timeout mặc định đúng” cho một endpoint. Timeout đúng phụ thuộc vào ai gọi và họ chờ được bao lâu. Nếu batch job chấp nhận 5 phút thì endpoint đó phải chấp nhận 5 phút; nếu mobile UI chỉ chờ 3 giây thì cùng endpoint đó phải fail sau 3 giây. Client biết, server không.

Bắt đầu áp dụng

Không cần refactor cả hệ thống một lúc. Ba bước đầu tiên:

  1. Thêm header X-Request-Deadline vào gateway của bạn — set = now() + client_timeout. Từ đó về sau mọi service downstream đều có deadline để tham chiếu.
  2. Middleware ở mỗi service đọc header, gắn vào request context. Nếu chưa có → tự tạo default nhưng vẫn ở dạng tuyệt đối.
  3. HTTP client wrapper dùng ngân sách còn lại cho timeout, không dùng hằng số. Đây là chỗ ăn tiền lớn nhất.

Sau đó mới đến DB statement_timeout động, cancellation propagation, và queue worker deadline. Từng bước, mỗi bước đều đo được bằng metric ”% request bị hủy do deadline vs. % request timeout tự phát”.

Và một nguyên tắc để dán vào tường team: timeout không phải thuộc tính của server, là hợp đồng giữa client và server. Server đơn phương không được quyết định user chờ bao lâu. Cứ nhớ vậy là nửa bug loại này sẽ tự biến mất.

// reactions


cat comments.log


hoatq@dev : ~/blog $