Một trong những nguyên nhân phổ biến nhất khiến hệ thống phân tán sập dây chuyền là request treo vô hạn: service A gọi B, B chờ C, C kẹt — và A, B cứ giữ goroutine/connection chờ mãi, cạn tài nguyên, kéo sập cả chuỗi. gRPC có một cơ chế thanh lịch để chặn điều này: deadline gắn trong context. Client đặt một hạn chót; gRPC tự động gửi kèm nó theo request; mọi tầng phía sau biết "còn bao nhiêu thời gian" và dừng đúng lúc. Đây không phải một tính năng phụ — nó là nền tảng cho độ bền của cả hệ thống. Bài này (phần 5/12) đo thật cách deadline hoạt động trong go-lab.

Deadline đi kèm request, server dừng sớm

Khác với "timeout" cục bộ (chỉ client tự bỏ cuộc), deadline của gRPC được truyền sang server. Client đặt bằng context.WithTimeout:

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
resp, err := c.Do(ctx, req)   // deadline đi KÈM request sang server
// hết 100ms chưa có kết quả -> err code = DeadlineExceeded

Quan trọng hơn: server đọc được deadline đó và nên chủ động dừng khi hết giờ, qua ctx.Done():

func (s *srv) Do(ctx context.Context, r *Req) (*Resp, error) {
  select {
  case <-time.After(work):   // làm xong
    return resp, nil
  case <-ctx.Done():         // client huỷ / hết deadline -> DỪNG, trả lỗi
    return nil, status.FromContextError(ctx.Err()).Err()
  }
}

Ảnh chụp đoạn mã nền tối minh hoạ deadline và context một hạn chót lan truyền qua cả chuỗi gọi, client đặt deadline gRPC tự gửi kèm theo request server biết còn bao lâu và dừng sớm khi hết giờ không phí tài nguyên. Client đặt deadline bằng context.WithTimeout ctx cancel bằng context.WithTimeout background 100 millisecond defer cancel resp err bằng c.Do ctx req deadline đi kèm request sang server hết 100ms mà chưa có kết quả err code bằng DeadlineExceeded. Server kiểm ctx.Done để dừng sớm func Do ctx context r Req select case time.After work làm xong return resp nil case ctx.Done client huỷ hết deadline dừng trả lỗi return nil status FromContextError ctx.Err. Deadline lan truyền giảm dần qua từng tầng client đặt deadline 100ms service A đã dùng 30ms còn 70ms service A dùng cùng ctx gọi service B còn khoảng 65ms mỗi tầng biết thời gian còn lại của toàn chuỗi không ai làm quá hạn tổng

Hình 1: Client đặt deadline bằng context.WithTimeout; gRPC gửi kèm request. Server kiểm ctx.Done() để dừng sớm. Deadline lan truyền qua chuỗi gọi — mỗi tầng biết thời gian còn lại của toàn bộ chuỗi, giảm dần.

Đo thật: deadline quyết định thời điểm trả về

Mình chạy ba call với deadline khác nhau vào cùng một handler (server làm việc trong work_ms mili-giây):

Ảnh chụp bảng kết quả đo thật deadline quyết định thời điểm trả về output thật go-lab grpc-go v1.67.1 context.WithTimeout. Ba call với deadline khác nhau vào cùng handler, deadline 500 ms server làm 100 ms trả về sau 101 ms kết quả OK xong sau 100ms, deadline 100 ms server làm 500 ms trả về sau 100 ms kết quả DeadlineExceeded, deadline 50 ms server làm 1000 ms trả về sau 50 ms kết quả DeadlineExceeded, call trả về đúng lúc hết deadline 100ms 50ms không chờ hết thời gian server làm 500ms 1000ms client không bị treo chờ một việc đã quá hạn. Server dừng sớm khi hết deadline không phí tài nguyên log server khi deadline 100ms work 500ms server ctx.Done context deadline exceeded dừng xử lý sớm server nhận biết client đã hết giờ qua ctx.Done và dừng ngay thay vì chạy nốt 500ms vô ích trên hệ thống thật đây là điều ngăn một request quá hạn tiếp tục ngốn CPU DB goroutine chống quá tải lan rộng cascading failure

Hình 2: Đo thật. Deadline 500ms/work 100ms → OK sau 101ms. Deadline 100ms/work 500ms → DeadlineExceeded sau đúng 100ms. Deadline 50ms/work 1000ms → DeadlineExceeded sau 50ms. Call trả về đúng lúc hết deadline, không chờ hết việc. Server log ctx.Done và dừng sớm.

Kết quả thật:

  • Deadline đủ (500ms, việc 100ms): trả về OK sau 101ms — việc xong trước hạn, mọi thứ bình thường.
  • Deadline không đủ (100ms, việc 500ms): trả về DeadlineExceeded sau đúng 100ms — không chờ hết 500ms. Client lấy lại quyền điều khiển ngay khi hết hạn, không bị treo.
  • Deadline rất ngắn (50ms, việc 1000ms): DeadlineExceeded sau 50ms.

Điểm mấu chốt thứ hai: server dừng sớm. Log server khi deadline 100ms/việc 500ms in ctx.Done: context deadline exceeded -> dừng xử lý sớm. Server không chạy nốt 500ms vô ích — nó nhận biết qua ctx.Done() rằng client đã hết giờ và dừng ngay. Trên hệ thống thật, đây chính là cơ chế ngăn một request quá hạn tiếp tục ngốn CPU, giữ kết nối DB, chiếm goroutine — thứ gây quá tải lan rộng (cascading failure) khi tải cao.

Deadline lan truyền qua cả chuỗi

Sức mạnh lớn nhất là lan truyền. Khi service A nhận request với deadline 100ms, nếu A dùng cùng ctx đó để gọi tiếp service B, thì B nhận deadline đã trừ đi thời gian A đã tiêu. Cả chuỗi cùng tôn trọng một hạn chót tổng — không tầng nào làm vượt quá thời gian mà client còn chờ. Đây là lý do nên luôn truyền ctx xuống mọi lời gọi downstream (gRPC, DB, HTTP), thay vì tạo context.Background() mới (cắt đứt deadline).

Đánh đổi cần cân nhắc

Deadline phải được đặt — gRPC không tự áp mặc định. Nếu client không đặt deadline, request có thể chờ vô hạn (hoặc tới timeout mạng rất dài). Đây là lỗi phổ biến: quên đặt deadline cho mọi call là mở cửa cho treo dây chuyền. Quy tắc: mọi call gRPC ở client production nên có deadline hợp lý theo SLA.

Server phải chủ động kiểm ctx — nếu không, dừng sớm không xảy ra. gRPC huỷ việc gửi response khi hết deadline, nhưng nếu handler của bạn là một vòng tính toán/truy vấn không kiểm ctx.Done(), nó vẫn chạy tới hết rồi mới phát hiện. Tài nguyên vẫn bị phí. Với công việc nặng/dài, phải kiểm ctx.Err() định kỳ và truyền ctx vào mọi thao tác con (DB query nhận ctx, v.v.).

Deadline quá ngắn gây lỗi giả. Đặt deadline chặt quá so với thời gian xử lý thật sẽ khiến các request bình thường cũng bị DeadlineExceeded khi hệ thống hơi chậm (GC, tải cao), tạo lỗi và retry dồn dập làm mọi thứ tệ hơn. Deadline nên dựa trên p99 thực đo được cộng biên an toàn, không phải con số đoán. Cân bằng giữa "bỏ cuộc sớm" và "cho đủ thời gian".

Ba ý mang về

  1. Deadline gắn trong context và đi kèm request sang server. Đo thật: call deadline 100ms vào handler làm 500ms trả về sau đúng 100ms với code DeadlineExceeded — client không treo chờ một việc đã quá hạn. Đặt bằng context.WithTimeout.
  2. Server dừng sớm qua ctx.Done() để không phí tài nguyên. Đo thật: server log ctx.Done và dừng ngay thay vì chạy nốt 500ms. Đây là cơ chế chống quá tải lan rộng — request quá hạn không tiếp tục ngốn CPU/DB/goroutine.
  3. Luôn đặt deadline và luôn truyền ctx xuống downstream. gRPC không tự áp deadline (quên là treo vô hạn); server phải chủ động kiểm ctx cho việc dài; và deadline nên dựa trên p99 thật cộng biên — quá ngắn gây lỗi giả, quá dài mất tác dụng bảo vệ.

Nguồn

Phần sau ta thêm tầng middleware cho gRPC: interceptor — cách gắn logging, metrics, recover panic vào mọi RPC mà không sửa từng handler, cho cả unary lẫn streaming.