Timeout trong context Go ngăn các cuộc gọi DB chậm và request ngoài chất đống. Tìm hiểu cách lan truyền deadline, hủy, và các giá trị mặc định an toàn.

Một yêu cầu chậm hiếm khi chỉ là “chậm” một mình. Trong khi chờ, nó giữ một goroutine sống, chiếm bộ nhớ cho buffer và đối tượng phản hồi, và thường chiếm một kết nối database hoặc một slot trong pool. Khi đủ nhiều yêu cầu chậm tích tụ, API của bạn ngừng làm việc có ích vì tài nguyên giới hạn bị chặn chờ.
Bạn thường cảm nhận vấn đề ở ba nơi. Goroutine tăng lên và chi phí điều phối tăng, làm độ trễ tệ hơn cho mọi người. Pool database hết kết nối rảnh, khiến các truy vấn nhanh phải xếp hàng sau những truy vấn chậm. Bộ nhớ tăng do dữ liệu đang truyền và phản hồi đang xây dựng dở, điều này làm tăng công việc GC.
Thêm máy chủ thường không giải quyết được. Nếu mỗi instance đều chạm phải cùng một nút thắt (pool DB nhỏ, một upstream chậm, giới hạn tần suất chia sẻ), bạn chỉ chuyển hàng đợi đi và tốn tiền hơn trong khi lỗi vẫn tăng.
Hãy tưởng tượng một handler fan-out: nó nạp user từ PostgreSQL, gọi service thanh toán, rồi gọi service gợi ý. Nếu cuộc gọi gợi ý treo và không có gì hủy nó, request không bao giờ hoàn tất. Kết nối DB có thể được trả về, nhưng goroutine và tài nguyên client HTTP vẫn bị chiếm. Nhân điều đó lên hàng trăm request và bạn sẽ có một “meltdown” chậm.
Mục tiêu đơn giản: đặt giới hạn thời gian rõ ràng, dừng công việc khi hết giờ, giải phóng tài nguyên, và trả về lỗi dự đoán được. Timeout của context trong Go gán một deadline cho từng bước để công việc dừng lại khi người dùng không còn chờ nữa.
Một context.Context là một đối tượng nhỏ bạn truyền xuống chuỗi lời gọi để mọi lớp cùng đồng ý về một điều: khi nào request này phải dừng. Timeout là cách phổ biến để ngăn một phụ thuộc chậm giữ chặt server của bạn.
Context có thể mang ba loại thông tin: một deadline (khi công việc phải dừng), một tín hiệu hủy (ai đó quyết định dừng sớm), và vài giá trị phạm vi request (dùng tiết kiệm, và không bao giờ để dữ liệu lớn trong đó).
Hủy không phải là phép màu. Một context cung cấp kênh Done(). Khi kênh này đóng, request bị hủy hoặc thời gian đã hết. Code tôn trọng context sẽ kiểm tra Done() (thường bằng select) và trả về sớm. Bạn cũng có thể kiểm tra ctx.Err() để biết lý do kết thúc, thường là context.Canceled hoặc context.DeadlineExceeded.
Dùng context.WithTimeout để “dừng sau X giây.” Dùng context.WithDeadline khi bạn đã biết chính xác mốc cắt. Dùng context.WithCancel khi một điều kiện cha nên dừng công việc (client ngắt kết nối, người dùng rời trang, bạn đã có đáp án).
Khi một context bị hủy, hành vi đúng là nhàm chán nhưng quan trọng: dừng làm việc, ngừng chờ I/O chậm, và trả về lỗi rõ ràng. Nếu một handler đang chờ một truy vấn database và context kết thúc, trả về ngay và để truy vấn DB abort nếu driver hỗ trợ context.
Nơi an toàn nhất để dừng các request chậm là ranh giới nơi lưu lượng vào dịch vụ của bạn. Nếu một request sẽ timeout, bạn muốn điều đó xảy ra dự đoán được và sớm, không phải sau khi nó đã chiếm dụng goroutine, kết nối DB và bộ nhớ.
Bắt đầu ở edge (load balancer, API gateway, reverse proxy) và đặt một giới hạn cứng cho thời gian sống của bất kỳ request nào. Điều đó bảo vệ service Go của bạn ngay cả khi một handler quên thiết lập timeout.
Bên trong server Go, cấu hình các timeout HTTP để server không chờ mãi một client chậm hoặc một phản hồi bị treo. Ít nhất, cấu hình timeout cho việc đọc header, đọc body đầy đủ, ghi phản hồi, và giữ các kết nối idle.
Chọn ngân sách mặc định phù hợp với sản phẩm của bạn. Với nhiều API, 1–3 giây là điểm khởi đầu hợp lý cho các request thông thường, và giới hạn cao hơn cho các tác vụ chậm đã biết như export. Con số chính xác quan trọng ở mức độ nhất quán: đo lường và có quy tắc rõ ràng cho ngoại lệ.
Các phản hồi streaming cần chú ý thêm. Rất dễ tạo ra một stream vô hạn vô tình nơi server giữ kết nối mở và ghi các chunk nhỏ mãi mãi, hoặc chờ mãi trước byte đầu tiên. Quyết định từ đầu endpoint nào thật sự là stream. Nếu không phải, áp đặt thời gian tối đa tổng cộng và thời gian tối đa tới byte-đầu tiên.
Khi ranh giới đã có deadline rõ ràng, việc lan truyền deadline qua toàn bộ request trở nên dễ dàng hơn.
Nơi đơn giản nhất để bắt đầu là handler HTTP. Nó là điểm một request vào hệ thống của bạn, nên là nơi tự nhiên để đặt giới hạn cứng.
Tạo một context mới với deadline, và nhớ cancel nó. Sau đó truyền context đó vào mọi thứ có thể chặn: công việc database, cuộc gọi HTTP, hoặc các tính toán chậm.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
Một quy tắc tốt: nếu một hàm có thể chờ I/O, nó nên nhận một context.Context. Giữ handler dễ đọc bằng cách đẩy chi tiết vào các helper nhỏ như loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
Nếu deadline bị chạm (hoặc client ngắt kết nối), dừng công việc và trả về phản hồi thân thiện với người dùng. Một ánh xạ phổ biến là context.DeadlineExceeded sang 504 Gateway Timeout, và context.Canceled là “client is gone” (thường không có body trả về).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Mẫu này ngăn chặn tình trạng tích tụ. Khi timer hết, mọi hàm nhận biết context ở chuỗi gọi đều nhận cùng tín hiệu dừng và có thể thoát nhanh.
Khi handler của bạn có một context với deadline, quy tắc quan trọng nhất là: dùng chính ctx đó xuyên suốt vào gọi database. Đó là cách timeout thực sự dừng công việc thay vì chỉ ngăn handler chờ.
Với database/sql, ưu tiên các phương thức có context:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Nếu ngân sách handler là 2 giây, database chỉ nên nhận một phần trong đó. Dự trữ thời gian cho JSON encoding, các phụ thuộc khác, và xử lý lỗi. Một điểm bắt đầu đơn giản là cho Postgres 30%–60% của tổng ngân sách. Với deadline handler 2 giây, đó có thể là 800ms đến 1.2s.
Khi context bị hủy, driver sẽ yêu cầu Postgres dừng truy vấn. Thường thì kết nối được trả về pool và có thể tái sử dụng. Nếu hủy xảy ra trong lúc mạng có vấn đề, driver có thể loại bỏ kết nối đó và mở kết nối mới sau. Dù sao, bạn tránh được một goroutine chờ mãi mãi.
Khi kiểm tra lỗi, xử lý timeout khác với lỗi DB thực sự. Nếu errors.Is(err, context.DeadlineExceeded), bạn đã hết thời gian và nên trả về timeout. Nếu errors.Is(err, context.Canceled), client đã rời đi và bạn nên dừng yên lặng. Các lỗi khác là vấn đề truy vấn bình thường (SQL sai, row không tồn tại, quyền).
Nếu handler có deadline, các cuộc gọi HTTP đi ra cũng nên tôn trọng nó. Nếu không, client bỏ cuộc nhưng server của bạn vẫn chờ upstream chậm, chiếm goroutine, socket và bộ nhớ.
Tạo request ra ngoài với context cha để hủy lan truyền tự động:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
Timeout nhỏ cho mỗi lần gọi là mạng lưới an toàn. Deadline của parent vẫn là ông chủ thực sự. Một cái đồng hồ cho toàn bộ request, cộng các giới hạn nhỏ hơn cho các bước rủi ro.
Cũng hãy đặt timeout ở cấp transport. Context hủy request, nhưng timeout của transport bảo vệ bạn khỏi handshake chậm và server không bao giờ gửi header.
Một chi tiết hay gây lỗi cho đội: body phản hồi phải được đóng trên mọi nhánh. Nếu bạn trả về sớm (kiểm tra mã trạng thái, lỗi decode JSON, timeout context), vẫn phải đóng body. Rò rỉ body có thể âm thầm cạn kiệt kết nối trong pool và biến thành các spike độ trễ “ngẫu nhiên”.
Một kịch bản cụ thể: API của bạn gọi nhà cung cấp thanh toán. Client timeout sau 2 giây, nhưng upstream treo 30 giây. Nếu không có hủy request và timeout ở transport, bạn vẫn phải trả giá cho 30 giây chờ đó cho mỗi yêu cầu bị bỏ.
Một request thường chạm vào hơn một thứ chậm: công việc handler, một truy vấn DB, và một hoặc nhiều API ngoài. Nếu bạn cho từng bước một timeout rộng rãi, tổng thời gian sẽ âm thầm tăng lên cho đến khi người dùng cảm nhận được và server của bạn bắt đầu tích tụ.
Budgeting là cách sửa đơn giản nhất. Đặt một deadline cha cho toàn bộ request, rồi chia cho từng phụ thuộc những phần nhỏ hơn. Deadline con nên sớm hơn deadline cha để bạn fail fast và vẫn còn thời gian trả về lỗi đẹp.
Nguyên tắc chung phù hợp trong thực tế:
Tránh xếp chồng các timeout đấu nhau. Nếu handler có deadline 2 giây và client HTTP có timeout 10 giây, bạn an toàn nhưng bối rối. Nếu ngược lại, client có thể cắt sớm vì lý do không liên quan.
Với công việc nền (audit logs, metrics, email), đừng tái sử dụng context request. Dùng một context riêng với timeout ngắn để hủy client không làm chết các cleanup quan trọng.
Phần lớn bug về timeout không nằm ở handler. Chúng xảy ra một hoặc hai lớp sâu hơn, nơi deadline im lặng bị mất. Nếu bạn đặt timeout ở edge nhưng bỏ qua chúng ở giữa, bạn vẫn có thể có goroutine, truy vấn DB, hoặc cuộc gọi HTTP tiếp tục chạy sau khi client đã đi.
Các mẫu thường gặp đơn giản:
context.Background() (hoặc TODO). Điều đó cắt đứt công việc khỏi cancel của client và deadline handler.ctx.Done(). Request bị hủy nhưng code của bạn vẫn chờ.context.WithTimeout riêng. Bạn kết thúc với nhiều timer và deadline khó hiểu.ctx vào các lời gọi chặn (DB, HTTP ra ngoài, publish message). Timeout handler không có tác dụng nếu phụ thuộc phớt lờ nó.Một lỗi điển hình: bạn thêm timeout 2 giây ở handler, rồi repository dùng context.Background() cho truy vấn DB. Dưới tải, một truy vấn chậm tiếp tục chạy ngay cả sau khi client đã bỏ, và đống chờ ngày càng lớn.
Sửa những điều cơ bản: truyền ctx như đối số đầu tiên qua toàn bộ call stack. Trong công việc dài, thêm các kiểm tra nhanh như select { case <-ctx.Done(): return ctx.Err() default: }. Ánh xạ context.DeadlineExceeded thành phản hồi timeout (thường 504) và context.Canceled thành phản hồi kiểu client-cancel (thường 408 hoặc 499 tùy quy ước).
Timeout chỉ có ích nếu bạn thấy được chúng xảy ra và xác nhận hệ thống phục hồi gọn. Khi có chậm, request nên dừng, tài nguyên được giải phóng, và API giữ được tính phản hồi.
Với mỗi request, log cùng một tập trường nhỏ để bạn so sánh request bình thường và timeout. Bao gồm deadline của context (nếu có) và điều gì đã kết thúc công việc.
Các trường hữu ích bao gồm deadline (hoặc "none"), tổng thời gian trôi qua, lý do hủy (timeout vs client canceled), một nhãn thao tác ngắn ("db.query users", "http.call billing"), và request ID.
Một mẫu tối thiểu trông như sau:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
Logs giúp bạn gỡ lỗi một request. Metrics cho thấy xu hướng.
Theo dõi vài chỉ số thường tăng sớm khi timeout sai: số lượng timeout theo route và phụ thuộc, số request đang xử lý (in-flight) — nên ổn định dưới tải, thời gian chờ pool DB, và phần trăm độ trễ (p95/p99) tách theo thành công vs timeout.
Làm chậm có thể dự đoán. Thêm một delay chỉ dành cho debug vào một handler, làm chậm truy vấn DB bằng một wait cố ý, hoặc bọc cuộc gọi ngoài bằng một test server ngủ. Sau đó kiểm tra hai điều: bạn thấy lỗi timeout, và công việc thực sự dừng sau khi hủy.
Một load test nhỏ cũng hữu ích. Chạy 20–50 request đồng thời trong 30–60 giây với một phụ thuộc ép chậm. Số goroutine và request in-flight nên tăng rồi ổn định. Nếu chúng tiếp tục tăng, có thứ gì đó đang phớt lờ cancel của context.
Timeout chỉ có ích nếu được áp dụng mọi nơi mà request có thể chờ. Trước khi deploy, rà qua codebase và xác nhận cùng quy tắc được thực hiện ở mọi handler.
context.DeadlineExceeded và context.Canceled.http.NewRequestWithContext (hoặc req = req.WithContext(ctx)) và client có timeout ở transport (dial, TLS, response header). Tránh dựa vào http.DefaultClient trong đường dẫn production.Một "drill" nhỏ về phụ thuộc chậm trước khi phát hành rất đáng giá. Thêm delay nhân tạo 2 giây vào một truy vấn SQL và xác nhận ba điều: handler trả về đúng thời gian, cuộc gọi DB thực sự dừng (không chỉ handler), và logs của bạn rõ ràng báo đó là DB timeout.
Hãy tưởng tượng endpoint như GET /v1/account/summary. Một hành động của user kích hoạt ba việc: một truy vấn PostgreSQL (account + recent activity) và hai cuộc gọi HTTP ngoài (ví dụ, kiểm tra trạng thái billing và lookup enrichment profile).
Cho toàn bộ request một ngân sách cứng 2 giây. Không có ngân sách, một phụ thuộc chậm có thể giữ goroutine, kết nối DB và bộ nhớ bị chiếm cho đến khi API của bạn bắt đầu timeout khắp nơi.
Một chia đơn giản có thể là 800ms cho DB, 600ms cho external call A và 600ms cho external call B.
Khi bạn biết deadline tổng, truyền nó xuống. Mỗi phụ thuộc nhận timeout nhỏ hơn riêng, nhưng vẫn kế thừa cancel từ parent.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Nếu external call B chậm và mất 2.5 giây, handler của bạn nên dừng chờ ở 600ms, hủy công việc đang diễn ra, và trả về một phản hồi timeout rõ ràng cho client. Client thấy lỗi nhanh thay vì một spinner treo.
Logs của bạn nên làm rõ phần nào đã dùng hết ngân sách, ví dụ: DB xong nhanh, external A thành công, external B chạm giới hạn và trả context deadline exceeded.
Khi một endpoint thật hoạt động tốt với timeout và cancellation, biến nó thành một pattern lặp lại. Áp dụng end-to-end: handler deadline, cuộc gọi DB, và HTTP ra ngoài. Rồi sao chép cùng cấu trúc đó cho endpoint tiếp theo.
Bạn sẽ nhanh hơn nếu tập trung hóa các phần nhàm chán: helper đặt timeout ở ranh giới, wrapper đảm bảo ctx được truyền vào DB và HTTP, và một chuẩn ánh xạ lỗi cùng định dạng log.
Nếu bạn muốn thử nhanh pattern này, Koder.ai (koder.ai) có thể tạo các handler Go và service calls từ prompt chat, và bạn có thể export source để áp dụng các helper timeout và ngân sách của riêng bạn. Mục tiêu là nhất quán: các cuộc gọi chậm dừng sớm, lỗi trông giống nhau, và gỡ lỗi không phụ thuộc vào ai đã viết endpoint.
Một yêu cầu chậm giữ lại những tài nguyên giới hạn trong khi chờ: một goroutine, bộ nhớ cho buffer và đối tượng phản hồi, và thường là một kết nối cơ sở dữ liệu hoặc kết nối HTTP client. Khi đủ nhiều yêu cầu chờ đồng thời, các hàng đợi hình thành, độ trễ tăng cho toàn bộ lưu lượng, và dịch vụ có thể bị thất bại ngay cả khi từng yêu cầu về cơ bản vẫn có thể hoàn tất.
Đặt một thời hạn rõ ràng ở ranh giới yêu cầu (proxy/gateway và trong server Go), tạo một context có thời hạn trong handler, và truyền ctx đó vào mọi lời gọi có thể chặn (cơ sở dữ liệu và HTTP ra ngoài). Khi thời hạn hết, trả về nhanh với một phản hồi timeout nhất quán và dừng mọi công việc đang chạy nếu chúng hỗ trợ hủy.
Dùng context.WithTimeout(parent, d) khi bạn muốn “dừng sau khoảng thời gian này” — đây là cách phổ biến nhất trong các handler. Dùng context.WithDeadline(parent, t) khi bạn đã có một thời điểm cắt cố định cần tuân thủ. Dùng context.WithCancel(parent) khi một điều kiện nội bộ nên dừng công việc sớm, ví dụ “chúng ta đã có đáp án” hoặc “client đã ngắt kết nối.”
Luôn gọi hàm cancel, thường là defer cancel() ngay sau khi tạo context dẫn xuất. Cancel giải phóng timer và cho phép các công việc con nhận tín hiệu dừng rõ ràng, đặc biệt trong các đường dẫn trả về sớm trước khi deadline tự kích hoạt.
Tạo một context cho request một lần trong handler và truyền nó xuống như đối số đầu tiên cho các hàm có thể chặn. Một kiểm tra nhanh là tìm context.Background() hoặc context.TODO() trong đường dẫn xử lý request; những chỗ đó thường làm đứt nhịp lan truyền deadline của request.
Dùng các phương thức có context của database như QueryContext, QueryRowContext, và ExecContext (hoặc tương đương trong driver bạn dùng). Khi context kết thúc, driver có thể yêu cầu Postgres hủy truy vấn để bạn không tiếp tục tiêu tốn thời gian và kết nối sau khi request đã xong.
Gắn context của request cha vào yêu cầu ra ngoài bằng http.NewRequestWithContext(ctx, ...), và cấu hình thêm timeout ở cấp client/transport để bảo vệ trong quá trình kết nối, TLS, và chờ header phản hồi. Ngay cả khi gặp lỗi hoặc mã trả về không phải 200, luôn đóng resp.Body để kết nối trả về pool.
Chọn tổng thời gian cho request trước, rồi phân chia thời gian đó cho từng phụ thuộc nhỏ hơn sao cho còn đệm cho xử lý handler và mã hóa phản hồi. Nếu context cha chỉ còn rất ít thời gian, đừng khởi động một công việc tốn thời gian mà không thể hoàn tất trước deadline.
Một mặc định phổ biến là ánh xạ context.DeadlineExceeded sang 504 Gateway Timeout kèm thông báo ngắn như “request timed out.” Với context.Canceled, thường có nghĩa client đã ngắt kết nối; hành động tốt nhất thường là dừng công việc và không ghi body, để không lãng phí thêm tài nguyên.
Các lỗi thường gặp bao gồm: bỏ context request bằng cách dùng context.Background(), bắt đầu retry hoặc sleep mà không kiểm tra ctx.Done(), và quên gắn ctx vào các lời gọi có thể chặn. Một vấn đề tinh tế khác là đặt nhiều timeout rải rác khắp nơi, khiến sự cố khó lý giải và có thể cắt đứt sớm một cách bất ngờ.