go

Go production patterns mình mất 2 năm mới dùng đúng

Goroutine thì ai cũng biết. Worker pool, context propagation, error wrapping đúng cách — đây là những thứ thực sự phân biệt Go code chạy ổn và Go code gây leak lúc 2am.

2am, một service Go đang chậm dần đến mức timeout. PagerDuty vẫn im, nhưng latency tăng đều. Mở Grafana: goroutine count đang leo lên không dừng. Mình đã reproduce được bug đó ngay ngày hôm sau — và nó chỉ là 5 dòng code thiếu context.Done().

Go dễ bắt đầu. Nhưng có một khoảng cách khá lớn giữa “code chạy được” và “code chạy được 6 tháng mà không wake up ai lúc 2am”.

Context không phải chỉ để cancel request

Cái mình thấy nhiều nhất trong code review: pass context.Background() hoặc context.TODO() xuống tận DB layer, external API call. Kiểu như context chỉ là boilerplate để compiler vui.

Context có ba việc thực sự quan trọng:

1. Deadline propagation — cancel khi client disconnect, và deadline explicit thì propagate xuống mọi thứ downstream.

Chỗ hay bị hiểu nhầm: timeout của nginx/load balancer không tự động tạo ra deadline trên context.Context phía Go. r.Context() chỉ tự cancel khi client đóng connection hoặc handler return — nó không “biết” con số 3s cấu hình bên nginx. Muốn có deadline thật trong Go, phải khai báo explicit:

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    // Không dựa vào timeout của proxy — set deadline explicit ở đây
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()

    user, err := h.userRepo.FindByID(ctx, userID)
    if err != nil {
        // Nếu client disconnect, hoặc quá 3s, ctx đã cancelled
        // DB query tự cancel theo — không tốn resource nữa
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    // ...
}

func (r *UserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    // context được pass vào query — Postgres driver biết cancel khi ctx done
    row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
    // ...
}

r.Context() tự cancel khi client disconnect — cái đó đúng và miễn phí. Nhưng nếu anh em muốn một deadline cứng bất kể client có kiên nhẫn chờ hay không, phải tự WithTimeout. Đừng giả định proxy timeout và Go context deadline là một.

2. Cancellation — khi anh em cần cancel manually, không đợi deadline.

func processItems(ctx context.Context, items []Item) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // luôn defer cancel, không leak

    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err() // caller đã cancel
        default:
            if err := process(ctx, item); err != nil {
                cancel() // cancel các goroutine khác nếu có
                return err
            }
        }
    }
    return nil
}

3. Value propagation — dùng cho request-scoped data như trace ID, user ID. Không lạm dụng cho business logic.

type ctxKey string

const TraceIDKey ctxKey = "trace_id"

func WithTraceID(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, TraceIDKey, traceID)
}

func TraceIDFrom(ctx context.Context) string {
    v, _ := ctx.Value(TraceIDKey).(string)
    return v
}

// Middleware tự inject, handler tự lấy ra khi cần log
func TraceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = uuid.New().String()
        }
        ctx := WithTraceID(r.Context(), traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Rule ngắn: context.WithTimeout cho external calls, context.WithCancel khi anh em control cancellation, context.WithValue chỉ cho infra concerns (trace, auth).

Worker pool: đừng go func() không kiểm soát

Pattern hay gặp nhất gây goroutine leak:

// Nguy hiểm: tạo goroutine không giới hạn
func processAll(items []Item) {
    for _, item := range items {
        go process(item) // 10k items = 10k goroutines
    }
}

10k items bình thường. Nhưng nếu process gọi một service đang slow, 10k goroutine block, memory tăng, và anh em có incident.

Worker pool giải quyết bằng cách giới hạn concurrency. Chú ý validate workers > 0 trước — quên bước này thì loop spawn không chạy, nhưng job feed vẫn ghi vào channel như bình thường nên deadlock (buffered channel còn dễ tha, unbuffered thì treo chắc chắn):

func processAll(ctx context.Context, items []Item, workers int) error {
    if workers <= 0 {
        return fmt.Errorf("workers must be > 0, got %d", workers)
    }

    jobs := make(chan Item, len(items))
    errs := make(chan error, len(items))

    // Spawn fixed số worker
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for item := range jobs {
                if err := process(ctx, item); err != nil {
                    errs <- err
                }
            }
        }()
    }

    // Feed jobs
    for _, item := range items {
        jobs <- item
    }
    close(jobs) // signal workers không còn job nữa

    wg.Wait()
    close(errs)

    // Collect errors
    var errsOut []error
    for err := range errs {
        errsOut = append(errsOut, err)
    }
    return errors.Join(errsOut...)
}

Với context cancellation — khi ctx bị cancel giữa chừng, kết quả trả về nên phản ánh đúng lý do (ctx.Err()), không chỉ im lặng trả về các error đã thu thập được:

func processAll(ctx context.Context, items []Item, workers int) error {
    if workers <= 0 {
        return fmt.Errorf("workers must be > 0, got %d", workers)
    }

    jobs := make(chan Item)
    errs := make(chan error, len(items))
    var wg sync.WaitGroup

    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case item, ok := <-jobs:
                    if !ok {
                        return
                    }
                    if err := process(ctx, item); err != nil {
                        errs <- err
                    }
                case <-ctx.Done():
                    return
                }
            }
        }()
    }

    go func() {
        defer close(jobs)
        for _, item := range items {
            select {
            case jobs <- item:
            case <-ctx.Done():
                return
            }
        }
    }()

    wg.Wait()
    close(errs)

    var errsOut []error
    for err := range errs {
        errsOut = append(errsOut, err)
    }
    // ctx.Err() != nil nghĩa là mình dừng giữa chừng vì cancel/timeout,
    // không phải vì xử lý xong hết — caller cần biết sự khác biệt này
    if ctx.Err() != nil {
        errsOut = append(errsOut, ctx.Err())
    }
    return errors.Join(errsOut...)
}

Pattern spawn + sync.WaitGroup + error channel này chính là thứ package golang.org/x/sync/errgroup đóng gói sẵn. Nếu không cần tuning per-worker channel như trên, errgroup.WithContext thường gọn hơn — nó tự cancel context khi một goroutine lỗi, và tự gom lỗi đầu tiên mà không cần error channel tay.

workers thường là runtime.NumCPU() cho CPU-bound work, hoặc tune theo I/O throughput của external service cho I/O-bound work.

(Các snippet trên là excerpt minh họa cho pattern — Item, process() là placeholder, không phải code chạy được nguyên khối.)

Error wrapping: %w thay đổi cách debug

Trước Go 1.13, error handling kiểu này rất phổ biến:

// Cũ: mất context hoàn toàn
if err != nil {
    return fmt.Errorf("failed to get user: %s", err) // %s → string, mất type
}

Với %w, error được wrap — caller có thể unwrap và check type:

// Sentinel error — check bằng errors.Is
var ErrNotFound = errors.New("not found")

func (r *UserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)

    var user User
    if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("user %d: %w", id, ErrNotFound) // wrap
        }
        return nil, fmt.Errorf("user %d scan: %w", id, err)
    }
    return &user, nil
}

// Caller có thể check sentinel qua chuỗi wrap bất kỳ độ sâu
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.userRepo.FindByID(r.Context(), userID)
    if err != nil {
        if errors.Is(err, ErrNotFound) { // vẫn match dù đã wrap
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    // ...
}

Khi cần carry thêm data trong error, dùng custom error type:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s%s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{Field: "age", Message: "must be between 0 and 150"}
    }
    return nil
}

// errors.As để unwrap vào concrete type
func handleRequest(age int) {
    if err := validateAge(age); err != nil {
        var ve *ValidationError
        if errors.As(err, &ve) { // unwrap qua chain
            fmt.Printf("Bad field: %s\n", ve.Field)
        }
    }
}

Rule: dùng errors.Is cho sentinel errors (so sánh identity), errors.As khi cần access fields của concrete type.

sync.Once và sync.RWMutex: hai pattern hay bị dùng sai

sync.Once cho lazy initialization — đảm bảo đoạn code chỉ chạy đúng một lần, safe với concurrent access:

type DBPool struct {
    once sync.Once
    db   *sql.DB
    err  error
}

func (p *DBPool) Get(ctx context.Context) (*sql.DB, error) {
    p.once.Do(func() {
        // sql.Open tạo *sql.DB (pool handle) và thường chưa mở connection
        // thật; việc validate DSN/arguments phụ thuộc driver. Muốn biết DB
        // có connect được không, phải Ping/PingContext.
        p.db, p.err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
        if p.err != nil {
            return
        }
        if p.err = p.db.PingContext(ctx); p.err != nil {
            _ = p.db.Close()
            p.db = nil
            return
        }
        p.db.SetMaxOpenConns(25)
        p.db.SetMaxIdleConns(5)
        p.db.SetConnMaxLifetime(5 * time.Minute)
    })
    return p.db, p.err
}

once.Do chỉ chạy một lần dù 1000 goroutine call Get() cùng lúc. Không cần lock bên ngoài — nhưng để ý: nếu lần gọi đầu tiên fail (DB down lúc startup chẳng hạn), sync.Once vẫn đánh dấu là “đã chạy”. Mọi lần gọi Get() sau đó nhận lại đúng cái lỗi cũ, kể cả khi DB đã sống lại. sync.Once không tự retry — nếu cần retry thì phải tự quản lý state đó, không nhờ được Once.

sync.RWMutex cho workload đọc nhiều hơn ghi — nhiều reader có thể chạy đồng thời, writer thì exclusive:

type Cache struct {
    mu    sync.RWMutex
    items map[string]Item
}

func (c *Cache) Get(key string) (Item, bool) {
    c.mu.RLock()         // nhiều goroutine có thể RLock cùng lúc
    defer c.mu.RUnlock()
    item, ok := c.items[key]
    return item, ok
}

func (c *Cache) Set(key string, item Item) {
    c.mu.Lock()          // exclusive — block tất cả readers và writers
    defer c.mu.Unlock()
    c.items[key] = item
}

func (c *Cache) Delete(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.items, key)
}

Dùng sync.Mutex (không phải RWMutex) khi write nhiều ngang đọc — overhead của RWMutex khi write-heavy thực ra cao hơn Mutex thông thường.

Kết

  • defer cancel() ngay sau mỗi context.WithCancel hoặc context.WithTimeout. Quên cái này thì context (và goroutine đang chờ nó) có thể sống tới khi parent context bị hủy hoặc process dừng — với timeout ngắn hạn, resource cuối cùng vẫn được reclaim, nhưng đó là kiểu leak âm thầm mà anh em không muốn debug lúc 2am.
  • Worker pool thay vì unbounded goroutine spawn — validate workers > 0 trước khi spawn. Pick workers = runtime.NumCPU() làm default cho CPU-bound, tune theo benchmark cho I/O-bound. Nếu logic đơn giản, errgroup.WithContext thường sạch hơn tự viết channel + WaitGroup.
  • %w thay vì %s khi wrap error. errors.Is cho sentinel, errors.As cho custom type. Không mất type qua call chain, debug dễ hơn nhiều.