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.

Bài tập làm thử

Bài 1 (đọc hiểu code Go). Giải thích ý nghĩa của cú pháp định tuyến sau, và cho biết đây là tính năng từ phiên bản Go nào:

mux := http.NewServeMux()
mux.HandleFunc("GET /tasks",  listHandler)
mux.HandleFunc("POST /tasks", createHandler)
Đáp án

Đây là cú pháp định tuyến theo method + path trong http.ServeMux, có từ Go 1.22. "GET /tasks" chỉ khớp request GET tới đường dẫn /tasks, còn "POST /tasks" chỉ khớp POST cùng đường dẫn — cùng một path có thể có handler khác nhau theo method mà không cần kiểm tra r.Method thủ công trong handler, hay phải dùng thư viện bên thứ ba như gorilla/mux như trước Go 1.22.

Bài 2 (sửa lỗi / bẫy). Middleware sau được viết để chống panic và đếm request, nhưng có một lỗi tinh vi. Tìm và sửa:

func mid(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		reqCount := reqCount + 1 // đếm request
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic phuc hoi: %v", err)
				http.Error(w, "loi noi bo", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}
Đáp án

Lỗi: reqCount := reqCount + 1 khai báo biến cục bộ mới che khuất biến toàn cục, không đếm thật, và ngay cả nếu sửa thành gán trực tiếp một biến int thông thường thì việc đọc-ghi trên biến dùng chung từ nhiều goroutine (mỗi request một goroutine) không an toàn — sẽ mất đếm dưới tải đồng thời. Sửa đúng theo bài là dùng atomic.Int64:

var reqCount atomic.Int64

func mid(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic phuc hoi: %v", err)
				http.Error(w, "loi noi bo", http.StatusInternalServerError)
			}
		}()
		reqCount.Add(1)
		next.ServeHTTP(w, r)
	})
}

Bài 3 (đọc hiểu). Đo tải thật cho kết quả: p50 = 1.041 ms, p95 = 12.03 ms, p99 = 30.967 ms, max = 111.836 ms. Vì sao bài viết nhấn mạnh "đừng bao giờ đánh giá dịch vụ chỉ bằng trung bình hay p50"? Nguyên nhân của đuôi trễ p99/max theo bài là gì?

Đáp án

p50 chỉ 1ms trông rất tốt, nhưng nó chỉ nói lên trải nghiệm của một nửa request nhanh nhất — nó che giấu thực tế là 1% request chậm nhất (p99) mất tới 31ms và request chậm nhất đo được lên tới 112ms. Người dùng thực tế có thể rơi vào nhóm chậm này, nên đánh giá dựa trên trung bình/p50 sẽ đánh giá quá lạc quan. Nguyên nhân của đuôi trễ theo bài là GC pause và tranh chấp khóa RWMutex lúc cao điểm.

Bài 4 (vận dụng thực tế). Server nhận tín hiệu SIGTERM khi Kubernetes thực hiện rolling update. Middleware/hạ tầng nào trong bài đảm bảo không có request nào bị rớt giữa chừng, và nó hoạt động ra sao?

Đáp án

Cơ chế graceful shutdown dùng srv.Shutdown(ctx). Khi nhận SIGTERM, server ngừng nhận request mới nhưng vẫn chờ các request đang xử lý dở hoàn tất trong một hạn định (ví dụ 5 giây, thông qua context.WithTimeout) trước khi tắt hẳn — nhờ đó không có request đang chạy nào bị cắt ngang giữa chừng, còn ListenAndServe trả về lỗi http.ErrServerClosed như tín hiệu tắt hoàn tất bình thường.

Bài 5 (đánh đổi / bẫy). Middleware recover() giúp server sống sót qua 3 lần panic liên tiếp trong bài đo thật. Nêu một giới hạn quan trọng của recover() trong middleware mà bạn cần biết để không hiểu nhầm nó là "lưới an toàn tuyệt đối".

Đáp án

recover() trong middleware chỉ bắt được panic xảy ra trong cùng goroutine đang xử lý request của handler. Nếu handler tự spawn một goroutine con (ví dụ go func() { ... }()) và goroutine con đó panic, middleware recover ở goroutine chính không cứu được — panic trong goroutine con không có ai bắt sẽ làm sập cả tiến trình. Vì vậy mỗi goroutine con tự sinh ra trong handler phải tự có recover() riêng. Ngoài ra, một panic được recover vẫn là bug cần sửa, không nên dùng panic/recover thay cho luồng điều khiển bằng error bình thường.

Phần sau là bài tổng kết cả sê-ri: bản đồ 132 bài và hai con số đo thật gói lại toàn bộ hành trình runtime, hiệu năng và hệ thống.