Đối mặt với “con quái vật” bộ nhớ
Một buổi sáng, tôi nhận thấy một microservice Go của mình ngốn tới 1.5GB RAM trong môi trường staging. Dịch vụ này vốn dĩ chỉ là một bộ tổng hợp dữ liệu đơn giản, và dựa trên logic, nó không nên vượt quá 200MB. Nếu điều này xảy ra ở môi trường production, OOM (Out of Memory) killer sẽ liên tục dừng tiến trình, gây gián đoạn dịch vụ.
Garbage collector (GC) của Go rất tuyệt vời, nhưng nó không phải phép màu. Nó không thể cứu bạn khỏi những lỗi logic khi bạn vô tình giữ lại tham chiếu đến các đối tượng không còn cần thiết. Để tìm ra những rò rỉ (leak) này, chúng ta cần pprof. Tôi đã áp dụng phương pháp này trong thực tế và kết quả luôn ổn định, giúp tôi cắt giảm mức sử dụng bộ nhớ tới 70% trong một số trường hợp.
Bắt đầu nhanh: Kích hoạt pprof trong 5 phút
Cách dễ nhất để bắt đầu profiling một ứng dụng web là thông qua package net/http/pprof. Nó tự động đăng ký các handler để cung cấp dữ liệu profiling qua HTTP.
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof" // Import để sử dụng side effects
"time"
)
func main() {
// Khởi chạy một server chạy ngầm cho pprof
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Logic ứng dụng của bạn ở đây
select {}
}
Chỉ bằng cách import _ "net/http/pprof", bạn đã mở một số endpoint tại /debug/pprof/. Bạn có thể truy cập http://localhost:6060/debug/pprof/ trên trình duyệt để xem danh sách các profile có sẵn, nhưng sức mạnh thực sự nằm ở công cụ dòng lệnh.
Đi sâu vào chi tiết: Hiểu về Heap Profile
Memory profiling trong Go chủ yếu tập trung vào heap. Heap là nơi các đối tượng tồn tại khi chúng vượt ra ngoài phạm vi của một hàm hoặc quá lớn để nằm trong stack. Để thu thập và phân tích một heap profile, hãy chạy lệnh sau trong terminal:
go tool pprof http://localhost:6060/debug/pprof/heap
Khi đã ở trong shell tương tác của pprof, bạn có hai cách chính để xem xét bộ nhớ:
- inuse_space: Hiển thị lượng bộ nhớ hiện đang được ứng dụng nắm giữ. Đây là lựa chọn tuyệt vời để tìm rò rỉ bộ nhớ (memory leak).
- alloc_space: Hiển thị tổng lượng bộ nhớ đã được cấp phát kể từ khi chương trình bắt đầu, ngay cả khi nó đã được dọn dẹp bởi garbage collector. Điều này hữu ích để tìm “áp lực GC” (GC pressure) — những đoạn code tạo ra quá nhiều rác, khiến CPU phải làm việc vất vả hơn.
Để chuyển đổi giữa chúng, hãy gõ sample_index=inuse_space hoặc sample_index=alloc_space bên trong prompt của pprof.
Lệnh “top”
Gõ top10 để xem các hàm tiêu tốn nhiều bộ nhớ nhất. Bạn sẽ thấy các cột như flat (bộ nhớ được sử dụng bởi chính hàm đó) và cum (bộ nhớ được sử dụng bởi hàm đó và tất cả các hàm mà nó gọi).
(pprof) top10
Hiển thị các node chiếm 95MB, 98.2% trên tổng số 96.74MB
flat flat% sum% cum cum%
80MB 82.69% 82.69% 80MB 82.69% main.generateData
15MB 15.51% 98.20% 15MB 15.51% runtime.allocm
Sử dụng nâng cao: Trực quan hóa rò rỉ
Đọc các bảng văn bản cũng tốt, nhưng việc trực quan hóa đồ thị cuộc gọi (call graph) giúp việc phát hiện thủ phạm dễ dàng hơn nhiều. Nếu bạn đã cài đặt Graphviz, bạn có thể tạo một giao diện web trực quan hơn đáng kể.
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
Lệnh này sẽ mở một tab trong trình duyệt của bạn. Chế độ xem yêu thích của tôi ở đây là Flame Graph. Trong Flame Graph, chiều rộng của mỗi ô đại diện cho lượng bộ nhớ mà hàm đó (và các hàm con của nó) đang sử dụng. Nếu bạn thấy một thanh rất rộng trông có vẻ bất thường, bạn đã tìm thấy điểm nghẽn của mình.
Phát hiện rò rỉ trong thực tế
Tôi từng gặp tình huống một map[string]*User tăng trưởng vô hạn. Bằng cách sử dụng chế độ xem Peek trong giao diện web của pprof, tôi thấy một background worker cụ thể đang thêm người dùng vào map này nhưng không bao giờ xóa chúng. Trong công cụ pprof, tôi đã sử dụng lệnh list để xem chính xác dòng code nào chịu trách nhiệm:
(pprof) list main.WorkerProcess
ROUTINE ======================== main.WorkerProcess trong /app/main.go
40MB 40MB (flat, cum) 41.35% của tổng số
. . 38: func WorkerProcess(u *User) {
40MB 40MB 39: globalCache[u.ID] = u // Rò rỉ nằm ở đây!
. . 40: }
Mẹo thực tế để tối ưu hóa bộ nhớ
Sau khi sử dụng pprof để tìm ra vấn đề, bạn cần khắc phục chúng. Dưới đây là những mẫu (pattern) hiệu quả nhất mà tôi thường dùng để giữ cho mức sử dụng bộ nhớ ở mức thấp.
1. Cấp phát trước cho Slice và Map
Nếu bạn biết trước sẽ có bao nhiêu phần tử trong một slice, hãy khởi tạo nó với một sức chứa (capacity). Điều này ngăn chặn việc cấp phát lại nhiều lần và sao chép dữ liệu khi slice mở rộng.
// Tệ: Cấp phát thường xuyên
var data []int
for i := 0; i < 1000; i++ {
data = append(data, i)
}
// Tốt: Cấp phát một lần
data := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
data = append(data, i)
}
2. Sử dụng sync.Pool cho các cấp phát thường xuyên
Nếu ứng dụng của bạn thường xuyên tạo và hủy cùng một loại đối tượng (như JSON buffer hoặc một struct tạm thời), hãy sử dụng sync.Pool. Điều này cho phép GC tái sử dụng bộ nhớ thay vì liên tục giải phóng và cấp phát lại.
3. Cẩn thận với Slice của Slice
Một sai lầm phổ biến là lấy một slice con nhỏ từ một slice rất lớn. Slice nhỏ đó vẫn giữ tham chiếu đến mảng cơ sở lớn, ngăn cản mảng lớn đó bị dọn dẹp bởi garbage collector. Để khắc phục, hãy sao chép dữ liệu sang một slice mới nhỏ hơn.
// Nguy cơ rò rỉ: smallPart giữ toàn bộ bigData trong bộ nhớ
bigData := make([]byte, 100*1024*1024) // 100MB
smallPart := bigData[:10]
// Giải pháp: Sao chép dữ liệu
smallPart := make([]byte, 10)
copy(smallPart, bigData[:10])
4. Cảnh giác với rò rỉ Goroutine
Mỗi goroutine tiêu tốn tối thiểu 2KB bộ nhớ stack. Nếu bạn chạy các goroutine không bao giờ kết thúc (ví dụ: chờ trên một channel không bao giờ đóng), cuối cùng bạn sẽ cạn kiệt bộ nhớ. Hãy sử dụng pprof/goroutine để kiểm tra xem số lượng goroutine có tăng liên tục theo thời gian hay không.
Tối ưu hóa bộ nhớ không phải là tối ưu hóa vi mô từng dòng code; mà là xác định 20% đoạn code gây ra 80% áp lực bộ nhớ. Bằng cách tích hợp pprof vào quy trình làm việc, bạn sẽ chuyển từ việc suy đoán sang biết chính xác từng byte dữ liệu của mình đang đi đâu.

