Context trong Go: truyền dữ liệu, deadline, cancel - dùng đúng hay chết hiệu năng

Mở bài
context.Context
là mảnh ghép nhỏ nhưng ảnh hưởng lớn tới độ ổn định và hiệu năng của dịch vụ Go. Dùng đúng: request dừng đúng lúc, hệ thống nhẹ nhàng. Dùng sai: rò rỉ goroutine, deadline chồng chéo, context.Value
bị lạm dụng, CPU nhảy vọt.
Bài này tóm gọn cách tôi áp dụng context trong dự án backend: truyền dữ liệu gì, đặt deadline/timeout/cancel ở đâu, những lỗi làm chết hiệu năng, kèm ví dụ thực tế theo ngữ cảnh quán cà phê/đồ ăn để dễ hình dung.
Context làm 3 việc - không hơn
- Huỷ (cancellation): báo cho các goroutine dừng việc dang dở.
- Deadline/Timeout: giới hạn thời gian cho tác vụ.
- Truyền dữ liệu nhẹ, cross-cutting: metadata kiểu request ID, user ID, locale. Không phải kho dữ liệu lưu mọi thứ.
Hãy luôn tự hỏi: “Giá trị này có thực sự là ngữ cảnh thực thi hay chỉ là tham số nghiệp vụ?” Nếu là tham số nghiệp vụ, truyền qua tham số hàm/struct; đừng nhét vào context.
Truyền dữ liệu đúng cách với context.Value
- Chỉ dùng cho metadata nhỏ:
request_id
,user_id
,trace_id
,locale
,permissions
tóm tắt. - Dùng key kiểu riêng để tránh xung đột:
package ctxkeys
type key string
const (
RequestID key = "request_id"
UserID key = "user_id"
)
Sử dụng:
ctx = context.WithValue(ctx, ctxkeys.RequestID, reqID)
if id, ok := ctx.Value(ctxkeys.UserID).(string); ok { /* ... */ }
- Không bỏ object nặng (struct lớn, connection, repository, config toàn cục) vào context. Điều này khiến mọi
ctx.Value
trở nên đắt và khó kiểm soát vòng đời tài nguyên. - Không truy cập
ctx.Value
trong vòng lặp nóng (hot loop). Nếu cần, rút giá trị ra một lần rồi truyền xuống như biến cục bộ.
Deadline vs Timeout vs Cancel
- Cancel thủ công: bạn chủ động gọi
cancel()
khi không cần tiếp tục. - Timeout:
WithTimeout(ctx, d)
tự huỷ saud
. - Deadline:
WithDeadline(ctx, t)
tự huỷ khi tới thời điểmt
.
Quy tắc: đặt timeout ở rìa hệ thống (biên HTTP, gRPC, job worker). Ở bên trong, hoặc kế thừa timeout/đặt ngắn hơn cho call ngoại vi (DB/HTTP). Tránh đặt timeout chồng lên nhau không cần thiết.
Mẫu chuẩn trong HTTP handler
Ví dụ quán cà phê có API tạo đơn hàng (pha cà phê là tác vụ tốn thời gian):
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
// 1) Lấy ctx gốc từ request
ctx := r.Context()
// 2) Đặt timeout toàn request (biên dịch vụ)
// Quy ước: 1.5s đủ để ghi DB + gọi payment
ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
// 3) Gắn metadata nhẹ
reqID := newRequestID()
ctx = context.WithValue(ctx, ctxkeys.RequestID, reqID)
// 4) Gọi xuống các lớp tiếp theo, luôn truyền ctx là tham số đầu tiên
err := h.orderSvc.Create(ctx, parseBody(r))
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Timeout", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
http.Error(w, "Canceled", http.StatusRequestTimeout)
return
}
w.WriteHeader(http.StatusCreated)
}
Điểm mấu chốt: không tạo context.Background()
mới trong các lớp dưới. Luôn kế thừa ctx
từ trên xuống.
Với DB/HTTP client: timeout ở đâu?
- HTTP outbound: cấu hình
http.Client{ Timeout: ... }
hoặc dùngreq.WithContext(ctx)
để ràng buộc theo request. - DB (ví dụ Postgres với
database/sql
): dùngctx
trên từng query, hoặccontext.WithTimeout
ngắn hơn nếu query đặc thù.
Ví dụ gọi dịch vụ thanh toán (giới hạn 800ms trong 1.5s toàn request):
func (c *PaymentClient) Charge(ctx context.Context, in ChargeReq) error {
// rút ngắn trong biên lớn hơn để dành thời gian cho phần còn lại
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
reqBody, _ := json.Marshal(in)
req, _ := http.NewRequest(http.MethodPost, c.url+"/charge", bytes.NewReader(reqBody))
req = req.WithContext(ctx)
resp, err := c.http.Do(req)
if err != nil { return err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return fmt.Errorf("payment failed") }
return nil
}
Sai lầm thường gặp: vừa đặt client.Timeout
, vừa WithTimeout
ngắn hơn, lại thêm retry thiếu kiểm soát → tổng thời gian vượt quá biên request, tạo thác lỗi.
Fan-out/fan-in với errgroup.WithContext
Khi cần gọi song song: tính tồn kho, tính phí ship, kiểm mã giảm giá. Dùng errgroup
để hủy tất cả khi một nhánh lỗi/timeout.
import "golang.org/x/sync/errgroup"
func (s *OrderService) PrepareCheckout(ctx context.Context, cart Cart) (Total, error) {
g, ctx := errgroup.WithContext(ctx)
var inv Inventory
var ship ShippingFee
var coupon Discount
g.Go(func() error { var err error; inv, err = s.invSvc.Check(ctx, cart.Items); return err })
g.Go(func() error { var err error; ship, err = s.shipSvc.Calc(ctx, cart); return err })
g.Go(func() error { var err error; coupon, err = s.couponSvc.Validate(ctx, cart); return err })
if err := g.Wait(); err != nil { return Total{}, err }
return aggregate(inv, ship, coupon), nil
}
Ưu điểm: khi một nhánh nhận DeadlineExceeded
, context chung bị huỷ → các nhánh còn lại dừng sớm, tiết kiệm tài nguyên.
Những lỗi làm chết hiệu năng
- Tạo context mới tuỳ tiện: dùng
context.Background()
ở giữa call stack. Kết quả: mất dấu metadata, mất huỷ lan truyền, goroutine chạy… tới mãi. - Lạm dụng
context.Value
: nhét cả struct nặng, hoặc tra cứu giá trị liên tục trong vòng lặp. Hãy rút ra biến cục bộ, truyền tham số rõ ràng. - Timeout chồng chéo: handler 1.5s, mỗi call DB 1s, lại retry 3 lần/điểm → chắc chắn nổ. Cần ngân sách thời gian (time budget) tổng.
- Quên
defer cancel()
: tạoWithTimeout
/WithCancel
mà không huỷ → rò rỉ timer/goroutine chờ. Luôndefer cancel()
ngay sau khi tạo. - Bỏ qua
select { case <-ctx.Done(): }
trong worker/consumer dài hơi. Worker tiếp tục chạy dù request đã bị hủy → CPU/IO lãng phí. - Không propagate vào thư viện: quên truyền ctx vào HTTP client, DB, Redis… khiến deadline không hiệu lực.
- Đọc
ctx.Err()
muộn: khi xử lý batch lớn, cần kiểm tra sớm để “đổ ly cà phê” đúng lúc.
Worker & job dài hơi: dừng đúng lúc
Ví dụ quán cà phê có worker pha cà phê hàng loạt từ một queue. Mỗi mẻ pha không nên kéo dài nếu cửa hàng đóng.
func (w *Worker) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // đóng cửa: dừng pha
default:
}
job, err := w.queue.Pop(ctx)
if err != nil { return err }
// mỗi job có deadline riêng
jctx, cancel := context.WithTimeout(ctx, 2*time.Second)
err = w.handleJob(jctx, job)
cancel()
if err != nil { w.logger.Warn("job failed", "err", err) }
}
}
Lưu ý: Pop(ctx)
của queue cần hỗ trợ hủy qua ctx để không bị kẹt.
Logging & trace: dùng context vừa đủ
- Dùng context để mang
request_id
/trace_id
, nhưng logging nên structured và tạo logger ràng buộc 1 lần ở rìa.
logger := base.With("request_id", reqID)
ctx = context.WithValue(ctx, ctxkeys.RequestID, reqID)
// truyền logger xuống hoặc tạo helper lấy request_id từ ctx một lần
Tránh gọi ctx.Value
mỗi lần log trong hot path; tạo wrapper LoggerFrom(ctx)
trả về logger đã gắn field.
Test & benchmark ý nghĩa
- Test cancel: tạo context với timeout ngắn, giả lập tầng dưới ngủ và xác nhận đã dừng sớm.
- Benchmark
ctx.Value
vs biến cục bộ trong vòng lặp 1e6. Kinh nghiệm: biến cục bộ luôn nhanh và dễ tối ưu CPU hơn. - Chaos/latency injection: thêm độ trễ ngẫu nhiên vào HTTP/DB để kiểm tra ngân sách thời gian tổng thể.
Checklist áp dụng ngay
- Context là tham số đầu tiên của mọi hàm có khả năng I/O, blocking hoặc dài hơi.
- Đặt timeout ở rìa (HTTP/gRPC/job). Bên trong: kế thừa hoặc rút ngắn cho call ngoại vi.
- Luôn
defer cancel()
ngay sauWithTimeout/WithCancel
. - Không dùng
context.Background()
giữa call stack. Chỉ ở entry-point (main, tests) hoặc bootstrap. context.Value
chỉ cho metadata nhỏ; tránh trong hot loop; dùng key kiểu riêng.- Dùng
errgroup.WithContext
cho fan-out và hủy đồng loạt. - Thư viện/DAO/HTTP client phải nhận
ctx
và tôn trọngctx.Done()
. - Quan sát
context.DeadlineExceeded
vàcontext.Canceled
để trả mã lỗi đúng và metric đúng.
Kết luận
Context không phải “túi đồ vạn năng”, mà là kênh tín hiệu điều khiển thời gian sống của công việc. Khi ta đặt deadline rõ ở rìa, propagate đúng cách, truyền metadata tối thiểu, và tôn trọng ctx.Done()
ở mọi chỗ chờ I/O, hệ thống sẽ bớt cà khịa CPU và tránh rò rỉ goroutine. Ngược lại, chỉ một vài lỗi nhỏ — lạm dụng Value
, timeout chồng tầng, quên huỷ — là đủ làm hiệu năng tụt dốc.
Hãy bắt đầu từ những thay đổi nhỏ: thêm timeout ở handler, dùng errgroup.WithContext
, audit các điểm gọi DB/HTTP đã nhận ctx
chưa. Sau đó tối ưu tiếp theo ngân sách thời gian tổng. Giống như vận hành quán cà phê giờ cao điểm: ai cũng cần biết lúc nào nên dừng pha ly tiếp theo để kịp giờ trả khách.