Hình dung hai cách ghi chép. Một là viết nhật ký bằng văn xuôi: "đã xử lý đơn DH-9 cho khách, 150.5 đồng" — muốn tìm lại thì đọc và căng mắt, muốn lọc thì viết regex rồi cầu trời định dạng đừng đổi. Hai là ghi vào một cuốn sổ cái có cột: ma=DH-9, tien=150.5 — mỗi sự kiện một dòng, mỗi thông tin một cột bạn sắp xếp và lọc được tức thì. Go 1.21 đưa log/slog vào thư viện chuẩn — cuốn sổ cái ấy — và nó thay thế được logrus, zap cho phần lớn nhu cầu.
Vì sao không dùng log cũ
2026/08/13 03:11:30 main.go:18: thông điệp kèm tham số
Gói log cũ chỉ ghi chuỗi — trang nhật ký văn xuôi. Muốn lọc theo mã đơn hàng, bạn phải viết biểu thức chính quy và cầu cho định dạng không đổi.
Nó cũng không có mức log — không có cách tắt debug trong sản xuất mà không sửa mã.
slog: hai handler
t := slog.New(slog.NewTextHandler(os.Stdout, nil))
t.Info("đơn hàng đã xử lý", "ma", "DH-9", "tien", 150.5, "thanhCong", true)
time=2026-08-13T03:11:30.805Z level=INFO msg="đơn hàng đã xử lý" ma=DH-9 tien=150.5 thanhCong=true
j := slog.New(slog.NewJSONHandler(os.Stdout, nil))
{"time":"2026-08-13T03:11:30.805Z","level":"INFO","msg":"đơn hàng đã xử lý","ma":"DH-9","tien":150.5}
Text cho môi trường phát triển, JSON cho sản xuất. JSON làm ma thành trường truy vấn được — một cột trong sổ cái — thay vì một chuỗi phải bóc bằng regex.
Cú pháp là cặp khoá–giá trị xen kẽ. Số lẻ đối số thì slog thêm khoá !BADKEY thay vì panic — nên lỗi này không làm sập dịch vụ nhưng cũng dễ lọt.
With: gắn ngữ cảnh cố định
l := j.With("dichVu", "don-hang", "phienBan", "1.2.3")
l.Info("nhận request", "duongDan", "/don/9")
{"...","msg":"nhận request","dichVu":"don-hang","phienBan":"1.2.3","duongDan":"/don/9"}
With trả về logger mới mang sẵn các trường đó — đóng dấu sẵn mấy cột chung cho mọi dòng sắp ghi. Đây là cách gắn mã tương quan cho toàn bộ vòng đời một request:
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l := slog.With("maRequest", uuid.NewString())
ctx := context.WithValue(r.Context(), khoaLog{}, l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
With cũng tính toán trước phần định dạng, nên logger đã With ghi nhanh hơn việc lặp lại các cặp khoá–giá trị ở mỗi lời gọi.
Có slog.Group để lồng:
{"...","msg":"có nhóm","http":{"method":"GET","ma":200}}
Cái bẫy: đối số vẫn được tính khi mức log đã tắt
gọi 1000 lần Debug (đang tắt) -> hàm đối số chạy 1000 lần
Đây là điểm quan trọng nhất của bài, và nó giống hệt kết luận ở bài 98 sê-ri Java.
slog.Debug("ẩn", "v", tonKem()) — tonKem() là đối số, nên Go tính nó trước khi gọi Debug. Mức DEBUG có tắt hay không cũng không cứu được. Bạn nấu xong món đắt tiền rồi mới quyết định có ghi nó vào sổ hay không.
Với đối số là biến sẵn có thì không sao. Với đối số cần tính toán — duyệt slice, serialize JSON, gọi hàm — hãy canh cổng:
if logger.Enabled(ctx, slog.LevelDebug) {
logger.Debug("chi tiết", "du", tinhToanTonKem())
}
có canh cổng Enabled() -> hàm chạy 0 lần
LogAttrs: bản không cấp phát
logger.LogAttrs(ctx, slog.LevelInfo, "dòng log",
slog.Int("i", i), slog.String("ma", "DH-9"))
Info (any...) : 70 ms (100.000 dòng)
LogAttrs (Attr) : 69 ms
Ở phép đo này hai cách gần bằng nhau — tôi mong LogAttrs nhanh hơn rõ rệt và nó không như vậy.
Lý do là slog đã tối ưu đường any... khá tốt cho ít đối số, và chi phí thật nằm ở việc định dạng JSON cùng ghi tệp chứ không ở việc đóng hộp đối số.
Nên lời khuyên trung thực: dùng Info(...) cho dễ đọc, và chỉ đổi sang LogAttrs ở đường chạy cực nóng sau khi đã đo. Đừng viết LogAttrs khắp nơi vì nghe nói nó nhanh hơn.
Cấu hình cho sản xuất
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true, // thêm tệp:dòng
})
slog.SetDefault(slog.New(h))
SetDefault làm slog.Info(...) ở mức package dùng handler của bạn — tiện cho thư viện không muốn nhận logger qua tham số.
Ghi ra stdout, không ghi tệp. Docker và Kubernetes đã thu log từ đó, đúng như bài 98 sê-ri Java.
AddSource tốn thêm một chút vì phải lấy thông tin ngăn xếp; bật nếu bạn thấy đáng.
Đổi mức lúc chạy bằng slog.LevelVar:
var muc slog.LevelVar
muc.Set(slog.LevelInfo)
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &muc})
// sau này: muc.Set(slog.LevelDebug)
Rất đáng có một endpoint đổi mức log — bật debug trong năm phút để điều tra rồi tắt, không cần triển khai lại.
Che dữ liệu nhạy cảm
Cài slog.LogValuer để kiểu của bạn tự che khi log:
type MatKhau string
func (m MatKhau) LogValue() slog.Value { return slog.StringValue("***") }
Giờ mọi chỗ log giá trị đó đều ra ***, kể cả khi lập trình viên quên — một cái cột tự in *** thay vì nội dung thật. Đây là cách phòng tốt hơn nhiều so với dựa vào kỷ luật.
Nếu chỉ soi một thứ trong ba mươi giây, tìm những hàm bị gọi ngay trong đối số log:
grep -rn 'slog\.\(Debug\|Info\)(' --include='*.go' . | grep -E '\(\)|\.String\(\)|json\.Marshal'
Mỗi kết quả là một lời gọi hàm nằm trong đối số log. Nếu nó ở mức DEBUG và nằm trong vòng lặp nóng, bạn đang trả tiền cho những dòng log không ai nhìn thấy.
Mẫu số chung
Hai ý trong bài này đúng ở mọi ngôn ngữ. Một là cú chuyển hướng của cả ngành: từ log-là-văn-xuôi (grep + regex + cầu cho định dạng đừng đổi) sang log-là-bản-ghi-có-cấu-trúc (trường để truy vấn) — vì các hệ gom log (ELK, Loki, Datadog) lập chỉ mục theo trường. slog chỉ là bản Go của một thứ có khắp nơi: SLF4J + MDC của Java, Serilog của .NET, structlog của Python, pino của Node. Và hệ quả vận hành luôn giống nhau: ghi sự kiện ra stdout, để nền tảng thu (quy tắc "log là luồng sự kiện" của 12-factor).
Hai là một sự thật về hiệu năng đúng ở mọi ngôn ngữ tính đối số trước: chi phí của một lời gọi log được trả ngay tại chỗ gọi, bất kể dòng đó có được ghi ra hay không. debug("...", tonKem()) chạy tonKem() kể cả ở mức INFO — nên ngôn ngữ nào cũng mọc ra một cái cổng: isDebugEnabled() của SLF4J, đối số kiểu lambda/supplier, Enabled() của slog. Lời khuyên trung thực: chỉ canh cổng đúng cái đối số thật sự đắt, vì chi phí thực nằm ở định dạng cộng I/O chứ không ở bản thân lời gọi — đúng như 70 và 69 mili giây gần bằng nhau ở trên. Sợi chỉ chung: coi một dòng log là một sự kiện có cấu trúc ghi ra stdout, không phải một câu văn ghi vào tệp — và nhớ rằng đối số của một lời log bị chặn vẫn chạy, nên hãy canh cổng cái đắt, và để trường nhạy cảm tự che theo kiểu thay vì tin mọi người sẽ nhớ.
Ngày mai: graceful shutdown — nhận tín hiệu và đóng gọn.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau in ra gì với JSONHandler?
j := slog.New(slog.NewJSONHandler(os.Stdout, nil))
j.Info("đơn hàng đã xử lý", "ma", "DH-9", "tien", 150.5)
Đáp án
In ra một dòng JSON có cấu trúc, đại khái:
{"time":"...","level":"INFO","msg":"đơn hàng đã xử lý","ma":"DH-9","tien":150.5}
Khác với gói log cũ chỉ ghi chuỗi văn xuôi, slog với JSONHandler biến mỗi cặp khoá–giá trị ("ma", "DH-9", "tien", 150.5) thành một trường JSON riêng — có thể lọc và truy vấn được bằng các hệ gom log như ELK hay Loki, thay vì phải viết regex để bóc tách từ một chuỗi văn xuôi.
Bài 2 (sửa lỗi/tối ưu). Đoạn mã sau tính toán tốn kém ngay cả khi mức DEBUG đang tắt. Sửa lại để hàm tonKem() chỉ chạy khi mức DEBUG thực sự đang bật.
logger.Debug("chi tiết", "du", tonKem())
Đáp án
if logger.Enabled(ctx, slog.LevelDebug) {
logger.Debug("chi tiết", "du", tonKem())
}
Vấn đề: tonKem() là một đối số của hàm Debug, nên Go tính nó trước khi gọi Debug — bất kể mức log DEBUG có đang tắt hay không, tonKem() vẫn chạy đầy đủ. Phải "canh cổng" bằng logger.Enabled(ctx, slog.LevelDebug) trước khi gọi Debug, để chỉ tính tonKem() khi dòng log thực sự sẽ được ghi ra.
Bài 3 (vận dụng thực tế). Bạn muốn mọi dòng log trong suốt vòng đời một request HTTP đều tự động kèm theo một maRequest duy nhất, mà không phải gõ lại nó ở mỗi lời gọi log. Viết middleware dùng slog.With để làm việc này.
Đáp án
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l := slog.With("maRequest", uuid.NewString())
ctx := context.WithValue(r.Context(), khoaLog{}, l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
slog.With(...) trả về một logger mới đã "đóng dấu sẵn" trường maRequest, để mọi lời gọi log tiếp theo qua logger này (lấy ra từ context bằng khoaLog{}) đều tự động kèm theo trường đó, không cần lặp lại thủ công ở từng nơi log.
Bài 4 (bẫy/đánh đổi). Bài viết đo được Info(...) (đối số kiểu any...) và LogAttrs(...) (đối số kiểu Attr, không cấp phát) chạy gần như cùng tốc độ (70ms so với 69ms cho 100.000 dòng), dù trực giác nghĩ LogAttrs phải nhanh hơn hẳn. Giải thích nguyên nhân, và nêu lời khuyên thực dụng rút ra.
Đáp án
Nguyên nhân: slog đã tối ưu khá tốt đường any... cho trường hợp ít đối số, và chi phí thật của một lời log nằm ở việc định dạng JSON và ghi I/O, chứ không nằm ở việc đóng hộp (boxing) đối số vào any. Lời khuyên thực dụng: dùng Info(...) cho dễ đọc là mặc định hợp lý; chỉ chuyển sang LogAttrs ở đường chạy cực nóng sau khi đã đo thực tế cho thấy nó đáng — đừng viết LogAttrs khắp nơi chỉ vì nghe nói nó nhanh hơn mà chưa kiểm chứng.
Bài 5 (đọc hiểu — che dữ liệu nhạy cảm). Giải thích cơ chế của đoạn mã sau và tại sao nó là cách phòng tốt hơn "dựa vào kỷ luật của lập trình viên nhớ không log mật khẩu".
type MatKhau string
func (m MatKhau) LogValue() slog.Value { return slog.StringValue("***") }
Đáp án
Kiểu MatKhau cài đặt interface slog.LogValuer bằng method LogValue(). Bất cứ khi nào một giá trị kiểu MatKhau được truyền vào một lời gọi log của slog (kể cả khi lập trình viên vô tình log nguyên một struct chứa trường kiểu này), slog sẽ tự động gọi LogValue() để lấy ra "***" thay vì giá trị thật. Đây là cách phòng tốt hơn dựa vào kỷ luật con người, vì nó gắn việc che dữ liệu vào chính kiểu dữ liệu — mọi chỗ log giá trị đó đều tự động che, kể cả những chỗ lập trình viên quên mất là đang log dữ liệu nhạy cảm.