7 min read

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

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

  1. Huỷ (cancellation): báo cho các goroutine dừng việc dang dở.
  2. Deadline/Timeout: giới hạn thời gian cho tác vụ.
  3. 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ỷ sau d.
  • Deadline: WithDeadline(ctx, t) tự huỷ khi tới thời điểm t.

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ùng req.WithContext(ctx) để ràng buộc theo request.
  • DB (ví dụ Postgres với database/sql): dùng ctx trên từng query, hoặc context.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

  1. 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.
  2. 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.
  3. 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.
  4. Quên defer cancel(): tạo WithTimeout/WithCancel mà không huỷ → rò rỉ timer/goroutine chờ. Luôn defer cancel() ngay sau khi tạo.
  5. 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í.
  6. 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.
  7. Đọ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 structuredtạ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 sau WithTimeout/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ọng ctx.Done().
  • Quan sát context.DeadlineExceededcontext.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.