Phỏng Vấn & Sự Nghiệp • 10 phút đọc • 05/10/2026

Phỏng Vấn Deep Technical Go: Những Cái Bẫy Tách Senior Khỏi Middle

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Trao đổi phỏng vấn tại bàn
Ảnh: Tennessee Titans · CC BY 4.0

Ở vòng phỏng vấn Go cho vị trí senior, mình gần như không hỏi “channel là gì”. Câu hỏi thật là: đoạn code này in ra cái gì, và vì sao nó làm sập production lúc 2 giờ sáng. Middle trả lời đúng phần cơ chế rồi dừng. Senior phải đi thêm hai bước nữa. Một, hành vi đó đến từ tầng nào của runtime hay compiler. Hai, nó thành loại sự cố nào khi 5,000 request mỗi giây chạy qua.

1. Typed nil trong interface: nil mà vẫn khác nil

Câu hỏi. Hàm trả “nil” khi không tìm thấy, nhưng caller báo err != nil. Giải thích.

type MyError struct{ Code int }

func (e *MyError) Error() string { return fmt.Sprintf("code=%d", e.Code) }

func lookup(id string) error {
	var p *MyError // nil, nhưng type là *MyError
	if id == "" {
		return p // đây là gốc của vấn đề
	}
	return &MyError{Code: 404}
}

fmt.Println(lookup("") == nil) // false
var target *MyError
fmt.Println(errors.As(lookup(""), &target), target == nil) // true true
flowchart TB
  P["p := (*MyError)(nil)<br/>con trỏ nil"] --> I["var err error = p"]
  subgraph IF["Giá trị interface error"]
    T["type = *MyError"]
    V["value = nil"]
  end
  I --> IF
  IF --> Q{"err == nil ?"}
  Q -- "false ← đây là bẫy" --> W["Hàm trả về error KHÁC nil<br/>dù bên trong là nil pointer"]

Điều thực sự xảy ra. Giá trị interface là cặp (type, value), so sánh với nil chỉ đúng khi cả hai vế rỗng. Gán con trỏ nil vào interface làm vế type khác rỗng, nên interface đó khác nil. Vế thứ hai mới nguy: errors.As vẫn match theo type, gán thành công một con trỏ nil, rồi caller dereference. Vì Error() định nghĩa trên *MyError nên chỉ *MyError mới thoả error.

Vì sao senior phải biết. err != nil là guard phổ biến nhất trong code Go, và typed nil vô hiệu hoá nó trên đúng nhánh lỗi mà test happy path không chạm. Cách tránh thì đơn giản: đừng return p với con trỏ có thể nil. Viết return nil tường minh.

2. Slice aliasing: append ghi nhờ mảng nền của người khác

Câu hỏi. a in ra gì, và truyền slice vào hàm rồi append bên trong thì caller có thấy không?

a := []int{1, 2, 3, 4}

b := a[:2]             // len 2, cap 4 — dùng chung mảng nền
b = append(b, 9)       // còn cap nên ghi thẳng vào a[2]
fmt.Println(a)         // [1 2 9 4]

b = append(b, 7, 7, 7) // vượt cap → cấp mảng mới, a không đổi

c := a[:2:2]           // full slice expression: cap = 2
c = append(c, 99)      // luôn cấp mảng mới

func mutate(s []int) {
	s[0] = 99        // caller thấy: cùng mảng nền
	s = append(s, 7) // len của caller không đổi
}

Điều thực sự xảy ra. Slice là header ba trường (con trỏ, len, cap), truyền theo giá trị. Nhưng con trỏ trỏ vào mảng nền dùng chung, nên ghi vào phần tử thì hai bên cùng thấy. append chỉ cấp mảng mới khi vượt cap. Còn chỗ thì nó ghi thẳng vào mảng nền rồi tăng len của bản sao header — caller không thấy len mới, nhưng ô ở vị trí len cũ đã bị ghi đè. “append không đổi len của caller” đúng. “append không ảnh hưởng caller” thì sai. Muốn cô lập hẳn, dùng full slice expression a[:2:2], hoặc slices.Clone.

Vì sao senior phải biết. Bug này tạo dữ liệu sai mà không crash. Buffer dùng lại trong vòng lặp, parser giữ slice con của một mảng lớn, hàm “chỉ đọc” ghi đè dữ liệu caller. Không có stack trace nào chỉ vào chỗ sai.

3. Biến vòng lặp trước và sau Go 1.22: xem dòng go trong go.mod

Câu hỏi. Đoạn code này in ra gì, và câu trả lời phụ thuộc vào thứ gì?

funcs := make([]func(), 3)
for i := 0; i < 3; i++ {
	funcs[i] = func() { fmt.Print(i) }
}
for _, f := range funcs {
	f()
}
// go 1.21: 333
// go 1.22+: 012

Điều thực sự xảy ra. Trước 1.22, biến i khai báo một lần và dùng chung cho mọi vòng. Closure capture biến, không capture giá trị, nên cả ba closure đọc cùng một ô nhớ ở trạng thái cuối. Tương tự với biến giá trị của for range, kéo theo bẫy &v trả về cùng một con trỏ cho mọi vòng. Từ Go 1.22, compiler tạo biến mới cho mỗi vòng, cho cả hai dạng vòng. Chỗ nhiều người trả lời sai: hành vi mới gate theo language version trong go.mod, không theo toolchain. Module khai go 1.21 vẫn chạy ngữ nghĩa cũ dù build bằng Go 1.24.

Vì sao senior phải biết. Hai hệ quả trái chiều. Code cũ vẫn phải sửa vì chưa hưởng hành vi mới. Còn bump dòng go trong repo lớn là đổi ngữ nghĩa hàng nghìn vòng lặp trong một commit.

4. Channel: nil, đã close, và select ngẫu nhiên

Câu hỏi. Bốn tình huống cho hành vi gì: nil channel, channel đã close, và select khi nhiều case cùng ready?

var nilCh chan int
// nilCh <- 1   → block mãi mãi
// <-nilCh      → block mãi mãi
// select { case <-nilCh: } → case này không bao giờ được chọn

ch := make(chan int, 1)
ch <- 1
close(ch)
fmt.Println(<-ch, <-ch) // 1 0 — nhận zero value ngay, không block
for range ch {          // thoát ngay lập tức
}
ch <- 2 // panic: send on closed channel

done := make(chan struct{})
close(done)
tick := make(chan struct{})
close(tick)
for i := 0; i < 6; i++ {
	select {
	case <-done:
		fmt.Print("d")
	case <-tick:
		fmt.Print("t")
	}
}
// ví dụ: dttdtd — cả hai case luôn ready, chọn ngẫu nhiên đều

Điều thực sự xảy ra. close không xoá channel. Nó là broadcast: đánh dấu sẽ không còn giá trị gửi thêm. Vì thế người gửi mới là người đóng, và mọi receiver nhận zero value ngay thay vì block. Nhờ đó for range ch thoát đúng. Nhưng for { v := <-ch } cũng quay cuồng vô hạn nếu quên kiểm tra ok. Nil channel block vĩnh viễn, và đó là tính năng: case trên nil channel trong select bị vô hiệu hoá. Cuối cùng, select chọn uniformly at random giữa các case ready, không ưu tiên gì cả. Muốn ưu tiên thì tự viết hai tầng: một select có default thử trước rồi mới vào select chặn.

Vì sao senior phải biết. Send vào channel đã close là panic chết cả process nếu không recover, và nó thường đến từ goroutine đóng channel ở một tầng xa. Zero value im lặng cũng nguy không kém. Consumer nhận 0 như một nhiệt độ rồi xử lý như dữ liệu thật.

5. Map bị truy cập đồng thời: throw, không phải panic

Câu hỏi. Đoạn code này chết ở đâu, và recover có cứu được không?

m := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		for j := 0; j < 100_000; j++ {
			m[fmt.Sprint(id, ":", j)] = j
		}
	}(i)
}
wg.Wait()
// thường gặp: fatal error: concurrent map writes

Điều thực sự xảy ra. Map không có bất kỳ bảo vệ nào cho truy cập đồng thời. Runtime đặt một cờ nội bộ trong lúc ghi; hai goroutine cùng ghi bị phát hiện và runtime gọi throw, không phải panic. throw nghĩa là process chết ngay kèm stack dump của mọi goroutine, và recover không bắt được. Mọi request đang xử lý mất trắng. Tệ hơn, phát hiện là best-effort: có lần truy cập chồng lấn không kích hoạt cờ, và map hỏng cấu trúc âm thầm. sync.Map chỉ tối ưu cho hai access pattern — entry ghi một lần rồi đọc rất nhiều, hoặc các goroutine thao tác trên tập key rời rạc. Nó không có transaction nhiều key. Mặc định của mình: sync.RWMutex bọc map thường.

Vì sao senior phải biết. Chỉ cần thêm một goroutine ở một chỗ khác hoàn toàn: đếm, cache, registry metric. Chạy -race trong test là cách phát hiện sớm nhất.

6. sync.Mutex không reentrant: treo chứ không panic

Câu hỏi. Service treo không panic, CPU gần 0, pprof cho thấy mọi goroutine đứng ở sync.(*Mutex).Lock. Chuyện gì đã xảy ra?

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Add() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
	c.Reset() // Reset cũng Lock → self-deadlock
}

func (c *Counter) Reset() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n = 0
}

Điều thực sự xảy ra. sync.Mutex không theo dõi goroutine sở hữu và không có bộ đếm đệ quy. Goroutine đã giữ lock mà gọi lại Lock trên cùng mutex sẽ chặn chính nó. Không tầng nào phát hiện đây là “đệ quy” để nhả ra. Thêm nữa, Go chỉ in “all goroutines are asleep - deadlock!” khi mọi goroutine đều bị chặn. Một HTTP server còn goroutine chờ accept sẽ khiến vụ việc im lặng: vài request timeout, không panic. Ít người biết: sync.RWMutex cũng không có read-lock đệ quy, và RLock lồng nhau có thể deadlock khi writer đang chờ.

Vì sao senior phải biết. Cách sửa là tách hàm, không phải đổi loại lock: API public giữ Lock, method nội bộ gọi nhau qua bản ...Locked(). Và vì triệu chứng là treo chứ không crash, bạn cần metric goroutine bị block chứ không chỉ alert theo error rate.

7. Goroutine leak: hàm đã trả về, goroutine thì chưa

Câu hỏi. Hàm này trả lỗi timeout đúng hạn, vậy tài nguyên có được giải phóng không?

func fetch(ctx context.Context) ([]Item, error) {
	ch := make(chan []Item) // unbuffered
	go func() {
		items, err := db.Query(context.Background()) // bỏ qua ctx
		if err != nil {
			return
		}
		ch <- items // không ai nhận nữa → block vĩnh viễn
	}()

	select {
	case items := <-ch:
		return items, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

// sửa: make(chan []Item, 1) → send không bao giờ block,
// hoặc bên trong goroutine dùng select { case ch <- items: case <-ctx.Done(): }

Điều thực sự xảy ra. Caller rời đi qua nhánh ctx.Done(), nhưng goroutine bên trong vẫn nằm ở ch <- items mãi mãi. GC không thu hồi goroutine: nó là đối tượng sống của scheduler, giữ stack, closure và reference tới kết nối DB, buffer lớn trong scope. Context chỉ tạo cơ chế huỷ. Tuân theo nó là hợp tác chủ động, nên mọi điểm có thể block đều phải select ctx.Done(). Phát hiện thì có hai phần. runtime.NumGoroutine đo theo thời gian — số tuyệt đối vô nghĩa, xu hướng tăng đều sau warmup mới là tín hiệu. Và goroutine profile qua pprof để nhóm các stack đang block.

Đường đi của ca leak này ngắn thôi, nhìn hình thì nhanh hơn đọc code:

sequenceDiagram
  participant C as Caller
  participant G as Goroutine
  participant D as DB
  C->>G: go fetch(ctx)
  G->>D: db.Query(context.Background())
  D-->>G: items
  G->>G: gửi items vào channel (unbuffered)
  C->>C: select chọn nhánh ctx.Done() rồi return
  Note over G: không ai nhận nữa, goroutine nằm đó mãi

Vì sao senior phải biết. Leak goroutine không làm hỏng một request. Nó làm hỏng dần cả service: memory tăng tuyến tính, GC pressure tăng, mọi request khác chậm. Biến thể chí mạng là leak goroutine đang giữ connection từ pool. Pool cạn, cả hệ thống timeout, dù bug chỉ ở một endpoint.

8. defer trong vòng lặp: hết file descriptor ở file thứ 900

Câu hỏi. Hàm quét 100,000 file và chết ở file thứ 900 với too many open files. Tại sao?

func process(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // chạy khi process() return, không phải mỗi vòng
		if err := scan(f); err != nil {
			return err
		}
	}
	return nil
}

// sửa: tách một hàm cho mỗi vòng lặp
for _, p := range paths {
	if err := func() error {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close()
		return scan(f)
	}(); err != nil {
		return err
	}
}

Điều thực sự xảy ra. defer gắn vào frame của function, không phải block hay vòng lặp, và chúng chạy LIFO khi hàm return. 100,000 vòng nghĩa là 100,000 file descriptor còn mở. Con số đó vượt ulimit -n (thường 1024) rất lâu trước khi hết vòng. Cùng bẫy đó áp dụng cho connection và lock: tài nguyên bị giữ lâu hơn mức cần. Từ Go 1.14, open-coded defer làm defer gần như một lời gọi hàm thường, nên đừng bỏ defer để đóng tay. Một bẫy đi kèm: defer log.Println(v) đánh giá tham số ngay lúc defer nên đúng cả trước 1.22. Còn defer func() { log.Println(v) }() đọc v lúc hàm kết thúc, nên trước 1.22 mọi dòng log in cùng giá trị cuối. Và defer resp.Body.Close() khi resp nil sẽ panic ngay lúc return.

Vì sao senior phải biết. Bug này hiện ra theo quy mô dữ liệu. Fixture test nhỏ thì không bao giờ thấy.

9. time.After trong vòng lặp nóng: timer tích theo QPS

Câu hỏi. Đoạn vòng lặp này có vấn đề gì, và Go 1.23 đã thay đổi điều gì?

for {
	select {
	case msg := <-msgs:
		handle(msg)
	case <-time.After(200 * time.Millisecond): // cấp phát mỗi vòng
		flush()
	}
}

// dùng lại một timer
t := time.NewTimer(200 * time.Millisecond)
defer t.Stop()
for {
	select {
	case msg, ok := <-msgs:
		if !ok {
			return
		}
		handle(msg)
	case <-t.C:
		flush()
		t.Reset(200 * time.Millisecond)
	}
}

Điều thực sự xảy ra. time.After tạo một timer mới cùng một channel mỗi lần gọi. Trước Go 1.23, timer đó bị heap của runtime giữ tham chiếu cho tới khi nó kích hoạt, bất kể bạn có nhận giá trị hay không. Trong vòng lặp mà nhánh msg gần như luôn được chọn, timer tích tụ nên bộ nhớ tăng theo QPS. Từ 1.23, timer không còn được tham chiếu có thể bị GC thu hồi ngay cả khi chưa Stop, và channel của timer giờ là unbuffered nên Stop/Reset có bảo đảm mạnh hơn. Thay đổi này từ 1.23. Trên 1.21 hay 1.22 chi phí đó vẫn còn nguyên. Nhưng kể cả trên 1.23, NewTimer rồi Reset vẫn là mẫu đúng trong vòng lặp nóng, vì bỏ hẳn việc cấp phát mỗi vòng.

Vì sao senior phải biết. Chi phí này không hiện trong test đơn vị. Nó hiện trong allocation profile và RSS tăng theo traffic. Còn với timeout của một thao tác cụ thể, API đúng là context.WithTimeout chứ không phải select với time.After.

10. context.WithValue, errors.Is và errors.As: so sánh lỗi sau khi wrap

Câu hỏi. Khi nào dùng WithValue, và vì sao err == ErrNotFound không còn đúng sau khi wrap?

type ctxKey struct{} // key phải là type chưa export, không dùng string

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, ctxKey{}, id)
}

var ErrNotFound = errors.New("user: not found")

func (s *Store) LoadUser(ctx context.Context, id string) (*User, error) {
	row, err := s.db.QueryRow(ctx, q, id)
	if err != nil {
		return nil, fmt.Errorf("load user %s: %w", id, err) // %w giữ nguyên chuỗi
	}
	return row, nil
}

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
ctx = WithRequestID(ctx, r.Header.Get("X-Request-ID"))

if errors.Is(err, ErrNotFound) { /* 404 */ }
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) { /* mã lỗi cụ thể của driver */ }

Điều thực sự xảy ra. WithValue dành cho dữ liệu gắn với một request — request id, tenant, subject đã xác thực — chứ không phải túi đựng tham số tuỳ chọn. Mỗi lớp WithValue thêm một lần tra map cho mỗi lần Value. defer cancel() là bắt buộc: bỏ nó giữ timer và cả cây con sống tới khi context cha kết thúc (go vet có analyzer lostcancel bắt lỗi này). Huỷ chỉ lan một chiều. Huỷ cha thì huỷ con; con huỷ không ảnh hưởng cha. Về lỗi, == so sánh theo identity, nên sau một lần fmt.Errorf với %w — error mới bọc error cũ — so sánh trực tiếp thất bại. errors.Is lần theo Unwrap tìm trong cả chuỗi. errors.As lấy ra kiểu cụ thể để đọc field. Còn %v cắt chuỗi và err.Error() == "..." thì đổi một chữ là vỡ logic.

Vì sao senior phải biết. Retry, circuit breaker và mapping HTTP status đều dựa trên loại lỗi: timeout, deadlock, unique violation cần ba hành vi khác nhau. Phân loại bằng string thì nâng cấp driver sẽ biến retry đúng thành retry sai mà không test nào đỏ. Còn context không được tôn trọng ở tầng dưới thì timeout chỉ là trang trí.

Cách mình trả lời mấy câu kiểu này

Bốn bước, và bước cuối là bước nhiều người bỏ. Một, đoán output kèm giả định về phiên bản Go và dòng go trong go.mod. Hai, mô tả cơ chế ở tầng thấp nhất — interface là cặp type và value, slice là header trỏ vào mảng nền, select chọn ngẫu nhiên, throw khác panic. Ba, chỉ ra failure mode trong production: chết im lặng hay chết ồn, phụ thuộc tải hay phụ thuộc thứ tự, có qua được test happy path không. Bốn, nêu cách verify: test cho nhánh lỗi, race detector, profile.

Cả mười bẫy đều không nằm ở đường đi thuận lợi. Chúng nằm ở nhánh lỗi, ở vòng lặp thứ một trăm nghìn, ở goroutine thứ hai thêm vào sau này. Nên câu hỏi tiếp theo của mình thường là: test của bạn đã chạm tới nhánh đó chưa?

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.