Suốt sê-ri này ta đã mổ xẻ runtime, GC, scheduler, channel, chẩn đoán, hồ sơ hiệu năng. Bài capstone này gộp chúng lại thành thứ bạn thực sự triển khai: một REST API production-grade — không framework, chỉ thư viện chuẩn. Mục tiêu không phải "hello world" mà là một dịch vụ có đủ những gì cần cho sản xuất: định tuyến gọn, middleware chống sập, đếm an toàn dưới tải, timeout, và graceful shutdown. Rồi ta đo tải nó thật để thấy thư viện chuẩn Go mạnh đến đâu.

Định tuyến: net/http đủ dùng, không cần framework

Từ Go 1.22, http.ServeMux định tuyến được theo method + path — thứ trước đây phải cậy tới gorilla/mux hay chi. Không còn lý do kéo framework vào cho một API thường:

mux := http.NewServeMux()
mux.HandleFunc("GET /tasks",  listHandler)
mux.HandleFunc("POST /tasks", createHandler)
mux.HandleFunc("GET /health", healthHandler) // probe cho k8s

Store dùng map bảo vệ bằng sync.RWMutex (đọc dùng RLock cho phép nhiều reader song song, ghi dùng Lock). Handler đọc/ghi JSON bằng encoding/json, trả đúng mã trạng thái (201 Created cho POST, 400 cho JSON hỏng).

Ba trụ cột production: recover, atomic, graceful shutdown

Middleware recover là thứ tách một đồ chơi khỏi một dịch vụ thật. Mặc định, một panic trong handler sẽ giết cả tiến trình — một request lỗi hạ luôn nghìn request khác. Middleware bọc mọi handler trong recover():

func mid(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil { // một handler panic KHÔNG hạ cả server
				log.Printf("panic phuc hoi: %v", err)
				http.Error(w, "loi noi bo", http.StatusInternalServerError)
			}
		}()
		reqCount.Add(1) // atomic, an toàn dưới nghìn goroutine
		next.ServeHTTP(w, r)
	})
}

reqCount là atomic.Int64 — mỗi request Go phục vụ trên một goroutine riêng, nên bộ đếm dùng chung phải atomic (bài đồng thời đã đo hậu quả của đếm không đồng bộ). Graceful shutdown dùng srv.Shutdown(ctx): khi nhận SIGTERM (Kubernetes gửi lúc rolling update), server ngừng nhận request mới nhưng chờ request đang xử lý xong trong hạn 5 giây rồi mới tắt — không rớt request giữa chừng.

Ảnh chụp đoạn mã Go nền tối minh hoạ capstone REST API production-grade với thư viện chuẩn Go, một định tuyến theo method cộng path Go 1.22 không cần framework mux bằng http NewServeMux mux HandleFunc GET tasks listHandler POST tasks createHandler GET health healthHandler probe cho k8s, hai middleware recover chống sập cộng đếm request func mid next http Handler trả về http HandlerFunc defer func if err bằng recover khác nil một handler panic không hạ cả server log Printf panic phục hồi http Error w loi noi bo 500 reqCount Add 1 atomic an toàn dưới nghìn goroutine next ServeHTTP w r, ba timeout cộng graceful shutdown rút lui sạch không rớt request đang chạy srv bằng http Server Addr 8099 Handler mid mux ReadTimeout 5 giây WriteTimeout 10 giây go func nhận sigCh SIGTERM SIGINT k8s gửi khi rolling update ctx cancel bằng context WithTimeout 5 giây defer cancel srv Shutdown ctx ngừng nhận mới chờ request đang xử lý xong srv ListenAndServe trả ErrServerClosed khi Shutdown hoàn tất

Hình 1: Kiến trúc dịch vụ — định tuyến net/http theo method+path, middleware recover + đếm atomic, và graceful shutdown qua srv.Shutdown(ctx) khi nhận tín hiệu.

Đo tải thật: 16.000 req/s, không tinh chỉnh gì

Ta chạy server rồi bắn tải bằng một client Go 50 luồng đồng thời trong 5 giây (POST /tasks), đo thông lượng và độ trễ phân vị:

Tổng request : 80.570  (thành công 80.570, lỗi 0)
Thông lượng  : 16.114 req/s
Độ trễ p50   : 1,041 ms
Độ trễ p95   : 12,03 ms
Độ trễ p99   : 30,967 ms
Độ trễ max   : 111,836 ms

Con số nói lên nhiều điều. 16.114 req/s với 0 lỗi từ một server thư viện chuẩn không tinh chỉnh gì — Go cân tải cao nhờ mỗi request một goroutine rẻ, scheduler tự trải trên 10 core. Nhưng chú ý đuôi trễ: p50 chỉ ~1ms, nhưng p99 lên 31ms và max tới 112ms. Đó không phải nhiễu — đó là GC pause (bài GC đã đo) cộng tranh khóa RWMutex lúc cao điểm. Bài học kinh điển: đừng bao giờ đánh giá dịch vụ chỉ bằng trung bình hay p50; người dùng cảm nhận cái đuôi p99/max. Bộ đếm atomic khớp chính xác: /health báo req nhảy từ 1 lên 80.572 (1 lần trước + 80.570 tải + 1 lần gọi này) — không mất một lần đếm nào dù 50 goroutine cùng Add.

Ảnh chụp bảng kết quả đo tải thật nền tối trong go-lab Go 1.23 arm64 10 core, tải 50 luồng đồng thời 5 giây POST tasks thư viện chuẩn không tinh chỉnh tổng request 80570 thành công 80570 lỗi 0 thông lượng 16114 req trên giây độ trễ p50 1,041 ms một nửa request nhanh hơn 1ms p95 12,03 ms p99 30,967 ms đuôi trễ 1 phần trăm chậm nhất max 111,836 ms do GC pause cộng tranh khóa lúc cao điểm, bộ đếm atomic trong middleware khớp chính xác số request health trước tải ok true req 1 health sau tải ok true req 80572 bằng 1 cộng 80570 tải cộng 1 lần gọi này reqCount Add 1 chạy đúng dưới 50 goroutine đồng thời không mất đếm, middleware recover 3 lần panic nil map server không sập panic phục hồi assignment to entry in nil map gọi no lần 1 HTTP 500 gọi no lần 2 HTTP 500 gọi no lần 3 HTTP 500 gọi ok sau 3 panic HTTP 200 server vẫn sống, graceful shutdown nhận tín hiệu rút lui sạch server chạy tại 8099 nhận tín hiệu tắt mềm trong 5s SIGTERM k8s rolling update đã tắt sạch Shutdown chờ request đang chạy xong

Hình 2: Đo thật — 80.570 request 0 lỗi ở 16.114 req/s (p50 1ms, p99 31ms, max 112ms do GC+khóa); bộ đếm atomic khớp 80.572; server sống sót qua 3 panic (mỗi lần trả 500); và tắt sạch khi nhận SIGTERM.

Sống sót qua panic và tắt sạch

Hai hành vi production quan trọng nhất, đo thật:

panic phuc hoi: assignment to entry in nil map
gọi /no lần 1 -> HTTP 500
gọi /no lần 2 -> HTTP 500
gọi /no lần 3 -> HTTP 500
gọi /ok sau 3 panic -> HTTP 200  (server VẪN SỐNG)

Ba lần gọi endpoint cố tình panic (ghi vào nil map) đều bị middleware bắt, trả 500, ghi log — và endpoint lành /ok sau đó vẫn trả 200. Server không chết dù handler panic liên tục. Và khi nhận tín hiệu tắt, log cho thấy nhận tín hiệu, tắt mềm trong 5s... rồi đã tắt sạch — Shutdown() chờ các request đang chạy hoàn tất thay vì cắt phập.

Đánh đổi cần cân nhắc

Thư viện chuẩn đủ cho phần lớn API — framework là để đổi lấy tiện lợi, không phải tốc độ. 16.000 req/s không tinh chỉnh chứng minh net/http không phải nút thắt. Framework (Gin, Echo, Fiber) cho bạn binding/validation/middleware sẵn và cú pháp gọn hơn, nhưng cũng thêm phụ thuộc và một lớp trừu tượng phải học. Với API vừa và nhỏ, thư viện chuẩn (từ Go 1.22 có routing method+path) thường là lựa chọn ít nợ kỹ thuật nhất. Chọn framework khi tiện lợi xứng đáng, không phải vì tin nó "nhanh hơn".

recover trong middleware là lưới an toàn, không phải cách xử lý lỗi. Nó ngăn một bug hạ cả tiến trình — nhưng một panic được recover vẫn là bug cần sửa, không phải luồng điều khiển bình thường. Đừng dùng panic/recover thay cho trả error. Và recover chỉ bắt panic trong cùng goroutine của handler; nếu handler spawn một goroutine con rồi nó panic, middleware không cứu được — goroutine con phải tự recover (bài rò rỉ/panic đã bàn).

Đuôi trễ p99 là thứ cần tối ưu, và nó đến từ GC + khóa. p50 1ms trông tuyệt, nhưng max 112ms nghĩa là có request chậm hẳn. Muốn kéo đuôi xuống: giảm cấp phát để GC chạy ít hơn (bài escape analysis, sync.Pool), thay RWMutex bằng sharding hoặc cấu trúc lock-free nếu khóa là điểm nóng (đo bằng pprof mutex/block trước khi sửa), và cân GOGC/GOMEMLIMIT. Đừng tối ưu mù — profile chỉ ra điểm nóng thật rồi mới sửa.

Ba ý mang về

  1. Thư viện chuẩn Go dựng được REST API production-grade không cần framework: net/http (Go 1.22+) định tuyến method+path, RWMutex cho store, JSON chuẩn — đo thật 16.114 req/s với 0 lỗi trên 80.570 request, không tinh chỉnh gì.
  2. Ba trụ cột biến đồ chơi thành dịch vụ thật: middleware recover (đo thật server sống sót qua 3 panic, mỗi lần trả 500), bộ đếm atomic (khớp chính xác 80.572 dưới 50 goroutine đồng thời), và graceful shutdown qua srv.Shutdown(ctx) khi nhận SIGTERM (tắt sạch, không rớt request).
  3. Nhìn đuôi trễ, không nhìn trung bình: đo thật p50 chỉ 1ms nhưng p99 31ms và max 112ms — cái đuôi đến từ GC pause và tranh khóa, và đó mới là thứ người dùng cảm nhận; tối ưu nó bằng giảm cấp phát, giảm tranh khóa, và cân GC — sau khi profile, không đoán.