06 ก.ค. 2568·อ่าน 3 นาที

โปรไฟล์หน่วยความจำ Go เมื่อทราฟฟิกพุ่ง: ไกด์ pprof แบบลงมือทำ

การโปรไฟล์หน่วยความจำ Go ช่วยรับมือทราฟฟิกพุ่ง คำแนะนำแบบลงมือทำด้วย pprof เพื่อหาจุดร้อนการจัดสรรใน JSON การสแกนฐานข้อมูล และ middleware

โปรไฟล์หน่วยความจำ Go เมื่อทราฟฟิกพุ่ง: ไกด์ pprof แบบลงมือทำ

ผลของการพุ่งของทราฟฟิกต่อหน่วยความจำของบริการ Go

"การพุ่งของหน่วยความจำ" ในโปรดักชันไม่ค่อยหมายถึงตัวเลขเดียวที่เพิ่มขึ้นอย่างเรียบง่าย คุณอาจเห็น RSS (หน่วยความจำกระบวนการ) เพิ่มอย่างรวดเร็วในขณะที่ heap ของ Go แทบไม่ขยับ หรือ heap โตแล้วลดเป็นคลื่นเมื่อ GC ทำงาน ในเวลาเดียวกัน latency มักแย่ลงเพราะ runtime ใช้เวลามากขึ้นในการเคลียร์หน่วยความจำ

รูปแบบที่พบบ่อยในเมตริก:

  • RSS เพิ่มเร็วกว่าที่คาดและบางครั้งไม่ลดกลับทั้งหมดหลังสไปก
  • Heap in-use เพิ่ม แล้วลดเป็นรอบเมื่อ GC ทำงานถี่ขึ้น
  • อัตราการจัดสรรพุ่ง (ไบต์ที่จัดสรรต่อวินาที)
  • เวลา pause ของ GC และ CPU ที่ใช้โดย GC เพิ่ม แม้แต่ละ pause จะสั้น
  • ความหน่วงของคำขอเพิ่มและ tail latency มีสัญญาณรบกวนมากขึ้น

การพุ่งของทราฟฟิกขยายการจัดสรรต่อคำขอ เพราะ "การสูญเปล่า" เล็กๆ จะเพิ่มขึ้นตามโหลดเชิงเส้น หากคำขอหนึ่งสร้างออบเจ็กต์ชั่วคราวเพิ่ม 50 KB (บัฟเฟอร์ JSON ชั่วคราว ออบเจ็กต์สแกนแถว ข้อมูล context ของ middleware) ที่ 2,000 คำขอต่อวินาที คุณจะป้อนตัวจัดสรรประมาณ 100 MB ต่อวินาที Go รับโหลดได้มาก แต่ GC ยังคงต้องติดตามและปล่อยออบเจ็กต์เหล่านั้น เมื่อการจัดสรรมากกว่าการทำความสะอาด target heap จะโตขึ้น RSS ตามมา และคุณอาจชนขีดจำกัดหน่วยความจำ

อาการที่พบบ่อยคือ: orchestrator ฆ่า OOM, latency กระโดด, เวลาที่ใช้ใน GC เพิ่ม และบริการดู "ยุ่ง" แม้ว่า CPU จะไม่เต็ม คุณอาจเจอ GC thrash: บริการยังรันแต่จัดสรรและเก็บซ้ำไปมา จน throughput ตกเวลาที่ต้องการมากที่สุด

pprof ช่วยตอบคำถามหนึ่งอย่างได้เร็ว: เส้นทางโค้ดไหนจัดสรรมากที่สุด และการจัดสรรเหล่านั้นจำเป็นหรือไม่? โปรไฟล์ heap แสดงสิ่งที่ยังถูกอ้างอิง ณ ตอนนี้ มุมมองที่โฟกัสการจัดสรร (เช่น alloc_space) แสดงสิ่งที่ถูกสร้างแล้วทิ้ง

pprof จะไม่อธิบายทุกไบต์ของ RSS ได้ RSS ครอบคลุมมากกว่า heap ของ Go (สแต็ก เมตาดาต้าของ runtime แผนที่ OS การจัดสรร cgo การแตกชิ้น) pprof เหมาะที่สุดในการชี้จุดร้อนการจัดสรรในโค้ด Go ของคุณ ไม่ใช่การพิสูจน์ยอดรวมหน่วยความจำระดับคอนเทนเนอร์

ตั้งค่า pprof อย่างปลอดภัย (ทีละขั้น)

pprof ใช้ง่ายผ่าน endpoint HTTP แต่ endpoint เหล่านั้นสามารถเผยข้อมูลบริการได้มาก ปฏิบัติกับมันเหมือนฟีเจอร์แอดมิน ไม่ใช่ API สาธารณะ

1) เพิ่ม endpoint ของ pprof

ใน Go การตั้งค่าที่ง่ายที่สุดคือลง pprof บนเซิร์ฟเวอร์แอดมินแยกต่างหาก นั่นจะเก็บ route การโปรไฟล์ให้ออกจาก router หลักและ middleware

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	go func() {
		// Admin only: bind to localhost
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	// Your main server starts here...
	// http.ListenAndServe(":8080", appHandler)
	select {}
}

ถ้าเปิดพอร์ตที่สองไม่ได้ คุณสามารถแมป route pprof เข้าไปในเซิร์ฟเวอร์หลักได้ แต่จะเสี่ยงเผลอเปิดเผย endpoint ง่ายกว่า พอร์ตแอดมินแยกเป็นค่าเริ่มต้นที่ปลอดภัยกว่า

2) ล็อกมันก่อนนำขึ้นผลิต

เริ่มด้วยการควบคุมที่ยากจะทำผิด ผูกกับ localhost หมายถึง endpoint ไม่สามารถเข้าถึงจากอินเทอร์เน็ตได้เว้นแต่มีการเปิดพอร์ตนั้นออกไปด้วยวิธีอื่น

เช็คลิสต์อย่างรวดเร็ว:

  • รัน pprof บนพอร์ตแอดมิน ไม่ใช่พอร์ตหลักที่ผู้ใช้เข้าถึง
  • ผูกกับ 127.0.0.1 (หรืออินเทอร์เฟซส่วนตัว) ในโปรดักชัน
  • เพิ่ม allowlist ที่ขอบเครือข่าย (VPN, bastion, หรือ subnet ภายใน)
  • เรียกใช้งาน auth ถ้าขอบเครือข่ายรองรับ (basic auth หรือ token)
  • ยืนยันว่าคุณดึงโปรไฟล์ที่ใช้จริงได้: heap, allocs, goroutine

3) สร้างและปล่อยอย่างปลอดภัย

เก็บการเปลี่ยนแปลงให้เล็ก: เพิ่ม pprof ส่งขึ้น แล้วยืนยันว่าเข้าถึงได้เฉพาะที่คาดไว้ ถ้ามี staging ให้ทดสอบโดยจำลองโหลดและจับ heap กับ allocs ใน staging ก่อน

สำหรับโปรดักชัน โรลเอาต์แบบค่อยเป็นค่อยไป (หนึ่งอินสแตนซ์หรือส่วนเล็กของทราฟฟิก) ถ้า pprof ผิดพลาด ความเสียหายจะจำกัดและแก้ไขได้ง่าย

จับโปรไฟล์ที่เหมาะสมในช่วงสไปก

ช่วงสไปก ภาพเดียวไม่พอ ให้จับไทม์ไลน์สั้นๆ: ไม่กี่นาทีก่อนสไปก (baseline), ระหว่างสไปก (impact), และไม่กี่นาทีหลัง (recovery) วิธีนี้ช่วยแยกการเปลี่ยนแปลงจริงจากพฤติกรรมปกติได้ง่าย

ถ้าสามารถจำลองสไปกได้ด้วยโหลดควบคุม ให้จับรูปแบบคำขอ พารามิเตอร์ payload และความพร้อมกันให้ใกล้เคียงโปรดักชันที่สุด สไปกของคำขอเล็กแตกต่างจากสไปกที่ตอบกลับด้วย JSON ขนาดใหญ่อย่างมาก

จับทั้ง heap profile และ profile ที่โฟกัสการจัดสรร ทั้งสองตอบคำถามต่างกัน:

  • Heap (inuse) แสดงสิ่งที่ยังมีชีวิตและครอบครองหน่วยความจำตอนจับภาพ
  • การจัดสรร (alloc_space หรือ alloc_objects) แสดงสิ่งที่ถูกสร้างบ่อย แม้จะถูกปล่อยเร็ว

แพตเทิร์นที่ปฏิบัติได้: จับ heap หนึ่งครั้ง แล้วจับ allocation หนึ่งครั้ง แล้วทำซ้ำ 30–60 วินาทีหลัง เห็นสองจุดระหว่างสไปกช่วยให้ดูว่าเส้นทางต้องสงสัยคงที่หรือเร่งขึ้น

# examples: adjust host/port and timing to your setup
curl -o heap_during.pprof "http://127.0.0.1:6060/debug/pprof/heap"
curl -o allocs_30s.pprof "http://127.0.0.1:6060/debug/pprof/allocs?seconds=30"

ควบคู่ไปกับไฟล์ pprof ให้บันทึกสถิติ runtime สั้นๆ เพื่ออธิบายว่า GC ทำอะไรในช่วงเวลานั้น ขนาด heap จำนวน GC และเวลา pause โดยรวมมักพอ แม้แต่บันทึกสั้นๆ ที่เวลาจับภาพก็ช่วยให้เชื่อมโยง "การจัดสรรเพิ่ม" กับ "GC เริ่มวิ่งตลอดเวลา"

จดบันทึกเหตุการณ์ขณะทำงาน: เวอร์ชันบิลด์ (commit/tag), เวอร์ชัน Go, flags สำคัญ, การเปลี่ยน config, และทราฟฟิกที่เกิดขึ้น (endpoints, tenants, ขนาด payload) รายละเอียดเหล่านี้สำคัญเมื่อเทียบโปรไฟล์ภายหลัง

วิธีอ่าน heap และ allocation profiles

โปรไฟล์ heap ตอบคำถามต่างกันขึ้นกับมุมมองที่ใช้

Inuse space แสดงสิ่งที่ยังอยู่ในหน่วยความจำ ณ เวลาจับภาพ ใช้ตรวจหาการรั่วไหล cache ที่ยาวนาน หรือคำขอที่ทิ้งออบเจ็กต์ไว้

Alloc space (การจัดสรรทั้งหมด) แสดงสิ่งที่ถูกสร้างตลอดเวลา แม้ว่าจะถูกปล่อยเร็ว ใช้เมื่อสไปกทำให้ GC ทำงานหนัก latency กระโดด หรือ OOM จาก churn

การสุ่มตัวอย่างสำคัญ Go ไม่ได้บันทึกการจัดสรรทุกรายการ มันสุ่มตัวอย่างการจัดสรร (ควบคุมด้วย runtime.MemProfileRate) ดังนั้นการจัดสรรเล็กๆ บ่อยครั้งอาจถูกประเมินต่ำ ตัวร้ายใหญ่ยังมักเด่น โดยเฉพาะในสภาวะสไปก มองหาแนวโน้มและผู้ก่อปัญหาอันดับต้น ๆ มากกว่าการนับเลขที่แม่นยำ

มุมมอง pprof ที่มีประโยชน์ที่สุด:

  • top: อ่านเร็วว่าใครครอง inuse หรือ alloc (ดูทั้ง flat และ cumulative)
  • list : แหล่งที่มาระดับบรรทัดในฟังก์ชันร้อน
  • graph: เส้นทางการเรียกที่อธิบายว่าคุณไปถึงจุดนั้นได้อย่างไร

การ diff ทำให้การใช้งานเป็นจริง เทียบโปรไฟล์ baseline กับโปรไฟล์สไปกเพื่อเน้นสิ่งที่เปลี่ยน แทนที่จะไล่เสียงรบกวนพื้นหลัง

ยืนยันข้อสรุปด้วยการเปลี่ยนเล็กๆ ก่อนรีแฟคเตอร์ใหญ่:

  • นำบัฟเฟอร์กลับมาใช้ใหม่ (หรือเพิ่ม sync.Pool) ในเส้นทางร้อน
  • ลดการสร้างออบเจ็กต์ต่อคำขอ (เช่น หลีกเลี่ยงการสร้างแมปชั่วคราวสำหรับ JSON)
  • โปรไฟล์ซ้ำภายใต้โหลดเดียวกันและยืนยันว่า diff ลดลงในจุดที่คาด

ถ้าตัวเลขเคลื่อนไหวไปในทิศทางที่ถูกต้อง นั่นหมายความว่าคุณหาสาเหตุจริงเจอ ไม่ใช่แค่รายงานที่น่ากลัว

หาจุดร้อนการจัดสรรใน encoding/json

ใช้โมเดลตอบกลับแบบมีชนิด
เปลี่ยน endpoint ที่เสี่ยงต่อสไปกให้เป็นโมเดลตอบกลับแบบมีชนิดที่ชัดเจนด้วยสคีมาทางภาพ
สร้างตอนนี้

ในสไปก งาน JSON สามารถกลายเป็นค่าใช้จ่ายหน่วยความจำหลักเพราะมันเกิดบนทุกคำขอ จุดร้อนของ JSON มักแสดงเป็นการจัดสรรเล็กๆ จำนวนมากที่เพิ่มภาระ GC

สัญญาณที่ควรระวังใน pprof

ถ้า heap หรือ allocation ชี้ไปที่ encoding/json ให้ดูสิ่งที่คุณป้อนเข้าไป พฤติกรรมเหล่านี้มักเพิ่มการจัดสรร:

  • ใช้ map[string]any (หรือ []any) สำหรับการตอบกลับแทน struct ที่มีชนิดชัดเจน
  • Marshal ออบเจ็กต์เดียวกันหลายครั้ง (เช่น เอาไปล็อกและส่งกลับพร้อมกัน)
  • Pretty printing ด้วย json.MarshalIndent ในโปรดักชัน
  • สร้าง JSON ผ่านสตริงชั่วคราว (fmt.Sprintf, การต่อสตริง) ก่อนการ marshal
  • แปลง []byte ขนาดใหญ่เป็น string (หรือกลับกัน) เพียงเพื่อให้ตรงกับ API

json.Marshal จะจัดสรร []byte ใหม่สำหรับเอาต์พุตทั้งหมดเสมอ json.NewEncoder(w).Encode(v) มักจะหลีกเลี่ยงบัฟเฟอร์ใหญ่ชิ้นเดียวเพราะมันเขียนลง io.Writer แต่ก็ยังอาจจัดสรรภายในได้ โดยเฉพาะหาก v มี any แมป หรือโครงสร้างที่ใช้พอยน์เตอร์มาก

แก้ปัญหาแบบเร็วและทดลองเร็ว

เริ่มจาก struct ที่พิมพ์ชนิดชัดเจนสำหรับรูปแบบการตอบกลับ ลดงาน reflection และการ boxing ของ interface ต่อฟิลด์

จากนั้นลบตัวแปรชั่วคราวที่หลีกเลี่ยงได้ต่อคำขอ: นำ bytes.Buffer กลับมาใช้ผ่าน sync.Pool (อย่างระมัดระวัง), หยุดการทำ indent ในโปรดักชัน, และอย่า marshal ซ้ำเพียงเพื่อ logging

การทดลองเล็กๆ เพื่อตรวจสอบว่า JSON เป็นต้นเหตุ:

  • แทน map[string]any ด้วย struct สำหรับ endpoint ที่ร้อนหนึ่งตัว แล้วเทียบโปรไฟล์
  • เปลี่ยนจาก Marshal เป็น Encoder เขียนตรงลง response
  • เอา MarshalIndent หรือการฟอร์แมต debug ออกและโปรไฟล์ใหม่ภายใต้โหลดเดียวกัน
  • ข้ามการเข้ารหัส JSON สำหรับการตอบกลับที่แคชไว้และวัดการลดลง

หาจุดร้อนการจัดสรรในการสแกนคิวรี

เมื่อหน่วยความจำพุ่งในสไปก การอ่านจากฐานข้อมูลเป็นสาเหตุที่มักถูกมองข้าม ง่ายที่จะโฟกัสเวลา SQL แต่ขั้นสแกนแถวสามารถจัดสรรมากต่อแถว โดยเฉพาะเมื่อสแกนเข้าไปในชนิดที่ยืดหยุ่น

ผู้กระทำผิดทั่วไป:

  • สแกนเข้า interface{} (หรือ map[string]any) และให้ไดรเวอร์ตัดสินชนิด
  • แปลง []byte เป็น string สำหรับทุกฟิลด์
  • ใช้ wrapper แบบ nullable (sql.NullString, sql.NullInt64) ในชุดผลลัพธ์ขนาดใหญ่
  • ดึงคอลัมน์ข้อความใหญ่/blob ที่คุณไม่ได้ต้องการเสมอ

หนึ่งแพตเทิร์นที่เผาผลาญหน่วยความจำอย่างเงียบๆ คือสแกนข้อมูลแถวลงตัวแปรชั่วคราว แล้วคัดลอกเข้า struct จริง (หรือสร้างแมปต่อแถว) ถ้าคุณสแกนตรงลง struct ที่มีฟิลด์คอนกรีต คุณจะหลีกเลี่ยงการจัดสรรเพิ่มและการตรวจชนิด

ขนาดแบตช์และการแบ่งหน้าเปลี่ยนรูปร่างหน่วยความจำ การดึง 10,000 แถวเข้า slice จะจัดสรรเพื่อขยาย slice และทุกแถวพร้อมกัน ถาผู้จัดการแค่ต้องการหน้า ให้บังคับในคิวรีและรักษาขนาดหน้าให้คงที่ ถ้าต้องประมวลผลแถวจำนวนมาก ให้ stream แล้วสรุปผลเล็กๆ แทนการเก็บทุกแถว

คอลัมน์ข้อความใหญ่ต้องการการดูแล ไดรเวอร์หลายตัวคืนข้อความเป็น []byte การแปลงเป็น string จะคัดลอกข้อมูล ดังนั้นการทำเช่นนี้สำหรับทุกแถวจะเพิ่มการจัดสรรอย่างมาก ถ้าคุณต้องการค่านั้นบางครั้งให้เลื่อนการแปลงหรือสแกนคอลัมน์ให้น้อยลงสำหรับ endpoint นั้น

เพื่อตรวจสอบว่าไดรเวอร์หรือโค้ดของคุณเป็นคนจัดสรรมากกว่า ให้ดูว่ากรอบสแต็กไหนโดดเด่นในโปรไฟล์:

  • ถ้าเฟรมชี้ไปที่โค้ดการแมปของคุณ ให้โฟกัสที่เป้าหมายการสแกนและการแปลง
  • ถ้าเฟรมชี้ไปที่ database/sql หรือไดรเวอร์ ให้ลดแถวและคอลัมน์ก่อน แล้วพิจารณาตัวเลือกเฉพาะไดรเวอร์
  • ตรวจทั้ง alloc_space และ alloc_objects; การจัดสรรเล็กๆ จำนวนมากอาจแย่กว่าการจัดสรรใหญ่ไม่กี่ครั้ง

ตัวอย่าง: endpoint "list orders" สแกน SELECT * ลง []map[string]any ในสไปกแต่ละคำขอสร้างแมปและสตริงเล็กๆ จำนวนมาก การเปลี่ยนคิวรีให้เลือกเฉพาะคอลัมน์ที่ต้องการและสแกนเป็น []Order{ID int64, Status string, TotalCents int64} มักลดการจัดสรรทันที ข้อคิดเดียวกันใช้ได้กับ backend ที่สร้างจาก AppMaster: จุดร้อนมักอยู่ที่การจัดรูปแบบและการสแกนผล ไม่ใช่ฐานข้อมูลเอง

แพตเทิร์น middleware ที่เงียบๆ จัดสรรต่อคำขอ

Go backend พร้อมโปรไฟล์
สร้าง backend Go แบบมองเห็นได้ แล้วโปรไฟล์โค้ดที่สร้างขึ้นด้วย pprof ภายใต้โหลดจริง
ลองใช้ AppMaster

Middleware รู้สึกถูกเพราะมันเป็น "แค่ wrapper" แต่ก็รันในทุกคำขอ ในสไปกการจัดสรรเล็กๆ ต่อคำขอจะสะสมอย่างรวดเร็วและปรากฏเป็นอัตราการจัดสรรที่เพิ่มขึ้น

logging middleware เป็นแหล่งทั่วไป: การฟอร์แมตสตริง สร้างแมปของฟิลด์ หรือคัดลอกเฮดเดอร์เพื่อให้อ่านง่าย ตัวช่วย Request ID อาจจัดสรรเมื่อสร้าง ID แปลงเป็นสตริง แล้วแนบลง context แม้แต่ context.WithValue ก็อาจจัดสรรถ้าคุณเก็บออบเจ็กต์ใหม่ (หรือสตริงใหม่) ทุกคำขอ

การบีบอัดและการจัดการ body เป็นอีกสาเหตุ ถ้า middleware อ่าน body ทั้งหมดเพื่อ "peek" หรือ validate คุณอาจได้บัฟเฟอร์ใหญ่ต่อคำขอ Gzip middleware อาจจัดสรรมากถ้ามันสร้าง reader และ writer ใหม่ทุกครั้งแทนการใช้บัฟเฟอร์ซ้ำ

ชั้น auth และ session ก็คล้ายกัน ถ้าทุกคำขอแยกวิเคราะห์ token ถอดรหัส base64 ของคุกกี้ หรือโหลด session blob ลง struct ใหม่ คุณจะได้ churn ต่อเนื่องแม้ handler จะงานเบา

tracing และ metrics อาจจัดสรรมากกว่าที่คาดเมื่อป้าย (labels) ถูกสร้างแบบไดนามิก การต่อชื่อ route, user agent, หรือ tenant ID เป็นสตริงใหม่ต่อคำขอเป็นต้นทุนที่ซ่อนอยู่

แพตเทิร์นที่มักปรากฏเป็น "death by a thousand cuts":

  • สร้าง log line ด้วย fmt.Sprintf และ map[string]any ใหม่ต่อคำขอ
  • คัดลอกเฮดเดอร์ลงแมปหรือ slice ใหม่สำหรับการล็อกหรือการเซ็น
  • สร้างบัฟเฟอร์ gzip และ reader/writer ใหม่แทนการ pool
  • สร้าง label metric ที่มี cardinality สูง (สตริงไม่ซ้ำมาก)
  • เก็บ struct ใหม่ใน context ทุกคำขอ

เพื่อตรวจสอบต้นทุน middleware ให้เปรียบเทียบสองโปรไฟล์: หนึ่งกับ chain เต็ม อีกหนึ่งปิด middleware หรือแทนที่ด้วย no-op การทดสอบง่ายๆ คือ endpoint สุขภาพที่ควรจะเกือบจะไม่จัดสรร ถ้า /health จัดสรรหนักในช่วงสไปก แสดงว่า handler ไม่ใช่ปัญหา

ถ้าคุณสร้าง backend Go ที่สร้างโดย AppMaster กฎเดียวกันใช้ได้: ทำให้ฟีเจอร์ข้ามชั้น (logging, auth, tracing) สามารถวัดผลได้ และมองการจัดสรรต่อคำขอเป็นงบที่ต้องตรวจสอบ

การแก้ที่มักได้ผลเร็ว

เพิ่มโมดูลโดยไม่ต้องต่อเชื่อม
เพิ่มโมดูลยืนยันตัวตนและชำระเงินโดยไม่ต้องต่อประกอบ middleware ที่เปราะบาง
เริ่มเลย

เมื่อคุณมี heap และ allocs จาก pprof ให้จัดลำดับการเปลี่ยนแปลงที่ลดการจัดสรรต่อคำขอก่อน เป้าหมายไม่ใช่ทริคฉลาด แต่ทำให้เส้นทางร้อนสร้างออบเจ็กต์ชั่วคราวน้อยลง โดยเฉพาะภายใต้โหลด

เริ่มจากวิธีปลอดภัยและธรรมดาที่ได้ผล

ถ้าขนาดคาดเดาได้ ให้จองความจุล่วงหน้า ถ้า endpoint ปกติคืนราว 200 รายการ ให้สร้าง slice ด้วยความจุ 200 จะลดการขยายและการคัดลอก

หลีกเลี่ยงการสร้างสตริงในเส้นทางร้อน fmt.Sprintf สะดวกแต่บ่อยครั้งจัดสรร สำหรับ logging ให้ใช้ฟิลด์แบบมีโครงสร้าง และนำบัฟเฟอร์เล็กๆ กลับมาใช้เมื่อสมเหตุสมผล

ถ้าคุณสร้าง JSON ขนาดใหญ่ ให้พิจารณาสตรีมมันแทนการสร้าง []byte หรือ string ก้อนใหญ่ pattern สไปกทั่วไปคือ: คำขอเข้ามา คุณอ่าน body ใหญ่ สร้าง response ใหญ่ หน่วยความจำกระโดดจน GC ตามไม่ทัน

การเปลี่ยนแปลงที่มักเห็นผลชัดในโปรไฟล์ก่อน/หลัง:

  • จองความจุล่วงหน้าสำหรับ slice และ map เมื่อรู้ช่วงขนาด
  • แทนที่การฟอร์แมตด้วย fmt ในการจัดการคำขอด้วยทางเลือกที่ถูกกว่า
  • สตรีม JSON ขนาดใหญ่ (encode ตรงไปยัง response writer)
  • ใช้ sync.Pool สำหรับออบเจ็กต์รูปร่างเดียวกันที่นำกลับมาใช้ซ้ำ (buffers, encoders) และคืนให้สม่ำเสมอ
  • ตั้งขีดจำกัดคำขอ (ขนาด body, ขนาด payload, ขนาดหน้า) เพื่อจำกัดกรณีเลวร้าย

การใช้ sync.Pool อย่างระมัดระวัง

sync.Pool ช่วยเมื่อคุณสร้างสิ่งเดิมซ้ำๆ เช่น bytes.Buffer ต่อคำขอ แต่มันอาจทำร้ายถ้าคุณ pool ออบเจ็กต์ที่มีขนาดไม่แน่นอนหรือไม่รีเซ็ตก่อนคืน ซึ่งจะรักษาแอเรย์ขนาดใหญ่ไว้

วัดก่อนและหลังโดยใช้ภาระงานเดียวกัน:

  • จับ allocs profile ระหว่างช่วงสไปก
  • ปรับหนึ่งการเปลี่ยนแปลงทีละอย่าง
  • รันคำขอชุดเดียวกันแล้วเปรียบเทียบ total allocs/op
  • ดู tail latency ไม่ใช่แค่หน่วยความจำ

ถ้าคุณสร้าง backend Go ที่สร้างโดย AppMaster การแก้เหล่านี้ยังใช้ได้กับโค้ดที่กำหนดเองรอบๆ handler, integration, และ middleware นั่นคือที่ที่การจัดสรรซ่อนตัวอยู่

ข้อผิดพลาดและสัญญาณเตือนปลอมที่พบบ่อยใน pprof

วิธีที่เร็วที่สุดในการเสียเวลาเป็นวันคือปรับจูนสิ่งที่ผิด ถ้าบริการช้า ให้เริ่มที่ CPU ถ้ามันถูกฆ่าเพราะ OOM ให้เริ่มที่ heap ถ้ามันยังรันแต่ GC ทำงานตลอด ให้ดูอัตราการจัดสรรและพฤติกรรม GC

กับดักอีกอย่างคืองมหัวที่ "top" แล้วคิดว่าสำเร็จแล้ว "top" ซ่อนบริบทเสมอ เสมอตรวจสอบ call stacks (หรือ flame graph) เพื่อดูว่าใครเรียก allocator แก้ปัญหามักอยู่หนึ่งหรือสองเฟรมด้านบนของฟังก์ชันร้อน

ดูความสับสนระหว่าง inuse กับ churn ด้วย คำขออาจจัดสรร 5 MB ของออบเจ็กต์ชั่วคราว ทำให้ GC เพิ่ม แต่สุดท้ายเหลือ inuse แค่ 200 KB ถ้าดูแต่ inuse คุณจะพลาด churn ถ้าดูแต่ total allocations คุณอาจปรับจูนสิ่งที่ไม่คงอยู่และไม่เสี่ยงต่อ OOM

เช็คลิสต์ความสมเหตุสมผลก่อนเปลี่ยนโค้ด:

  • ยืนยันว่าคุณอยู่ในมุมมองที่ถูกต้อง: heap inuse สำหรับ retention, alloc_space/alloc_objects สำหรับ churn
  • เปรียบเทียบสแต็ก ไม่ใช่แค่ชื่อฟังก์ชัน (encoding/json มักเป็นอาการ ไม่ใช่สาเหตุ)
  • จำลองทราฟฟิกให้สมจริง: endpoints เดียวกัน ขนาด payload เฮดเดอร์ และความพร้อมกัน
  • จับ baseline และโปรไฟล์สไปก แล้ว diff

การทดสอบโหลดที่ไม่สมจริงทำให้เกิดสัญญาณเตือนปลอม ถ้าการทดสอบส่ง body เล็กแต่โปรดักชันส่ง 200 KB คุณจะปรับจูนเส้นทางผิด ถ้าทดสอบคืนหนึ่งแถว คุณจะไม่เห็นพฤติกรรมการสแกนที่เกิดขึ้นจริงกับ 500 แถว

อย่าไล่เสียงรบกวน ถ้าฟังก์ชันปรากฏเฉพาะในโปรไฟล์สไปก (ไม่ใช่ baseline) นั่นเป็นเบาะแสที่แข็งแรง ถ้ามันปรากฏทั้งสองที่ระดับเดียวกัน อาจเป็นงานพื้นหลังปกติ

การเดินงานจริงในเหตุการณ์

สแตกเดียวสำหรับแอปทั้งหมด
สร้างเว็บและมือถือพร้อม backend ในที่เดียว บริหารตรรกะและข้อมูลจากที่เดียว
สร้างแอป

เช้าวันจันทร์มีโปรโมชั่นแล้ว API Go ของคุณได้ทราฟฟิก 8 เท่า อาการแรกไม่ใช่การ crash RSS เพิ่ม GC ทำงานหนักขึ้น p95 latency กระโดด endpoint ร้อนคือ GET /api/orders เพราะแอปมือถือรีเฟรชทุกครั้งที่เปิดหน้าจอ

คุณจับภาพสองชุด: หนึ่งจากช่วงสงบ (baseline) และหนึ่งระหว่างสไปก จับ heap ชนิดเดียวกันทั้งสองครั้งเพื่อให้การเปรียบเทียบยุติธรรม

ขั้นตอนที่ใช้งานได้ในเวลานั้น:

  • จับ heap profile baseline และบันทึก RPS, RSS, และ p95 latency ปัจจุบัน
  • ระหว่างสไปก จับ heap profile อีกหนึ่งและ allocation profile ภายในหน้าต่าง 1–2 นาทีเดียวกัน
  • เปรียบเทียบตัวจัดสรรอันดับต้นระหว่างทั้งสองและโฟกัสที่สิ่งที่เติบโตมากที่สุด
  • เดินจากฟังก์ชันที่ใหญ่ที่สุดไปยัง caller จนถึงเส้นทาง handler ของคุณ
  • ทำการเปลี่ยนแปลงเล็กๆ หนึ่งอย่าง ปล่อยไปยังอินสแตนซ์เดียว แล้วโปรไฟล์ใหม่

ในกรณีนี้ โปรไฟล์สไปกแสดงว่าการจัดสรรใหม่ส่วนใหญ่มาจากการเข้ารหัส JSON handler สร้างแถวเป็น map[string]any แล้วเรียก json.Marshal บน slice ของแมปแต่ละคำขอสร้างสตริงชั่วคราวและค่า interface จำนวนมาก

การแก้ไขที่ปลอดภัยที่สุดคือหยุดสร้างแมป สแกนแถวฐานข้อมูลตรงลง struct ที่มีชนิดชัดเจนแล้วเข้ารหัส slice นั้น รูปแบบการตอบกลับเดิมไม่เปลี่ยน หลังปล่อยการเปลี่ยนแปลงไปยังอินสแตนซ์หนึ่ง การจัดสรรในเส้นทาง JSON ลดลง เวลาของ GC ลดลง และ latency เสถียร

หลังจากนั้นค่อยโรลเอาต์แบบค่อยเป็นค่อยไปโดยดูหน่วยความจำ GC และอัตรา error ถ้าคุณสร้างบริการบนแพลตฟอร์ม no-code อย่าง AppMaster นี่เป็นการเตือนให้รักษาโมเดลการตอบกลับให้มีชนิดและสม่ำเสมอ เพราะช่วยหลีกเลี่ยงต้นทุนการจัดสรรที่ซ่อนอยู่

ขั้นตอนถัดไปเพื่อป้องกันสไปกครั้งถัดไป

เมื่อคุณทำให้สไปกสงบแล้ว ให้ทำให้ครั้งต่อไปน่าเบื่อ จัดการการโปรไฟล์เหมือนการฝึกซ้อมซ้ำได้

เขียน runbook สั้นๆ ให้ทีมปฏิบัติตามตอนเหนื่อย มันควรบอกว่าจะจับอะไร เมื่อไรจะจับ และวิธีเปรียบเทียบกับ baseline ที่รู้ว่าดี เก็บไว้เป็นเครื่องมือปฏิบัติ: คำสั่งที่แน่นอน ที่เก็บโปรไฟล์ และอะไรคือค่าปกติสำหรับตัวจัดสรรอันดับต้นของคุณ

เพิ่มการมอนิเตอร์เบาๆ สำหรับแรงกดดันการจัดสรรก่อนจะถึง OOM: ขนาด heap, จำนวน GC ต่อวินาที, และไบต์ที่จัดสรรต่อคำขอ การจับสัญญาณว่า "การจัดสรรต่อคำขอขึ้น 30% สัปดาห์ต่อสัปดาห์" มักมีประโยชน์กว่าการรอสัญญาณเตือนหน่วยความจำแข็ง

ขยับการตรวจสอบให้เร็วขึ้นด้วยการทดสอบโหลดสั้นๆ ใน CI บน endpoint ตัวแทน การเปลี่ยนเล็กน้อยของ response อาจเพิ่มการจัดสรรเป็นสองเท่าถ้ามันกระตุ้นการคัดลอกเพิ่ม และควรพบก่อนที่ทราฟฟิกโปรดักชันจะเจอ

ถ้าคุณรัน backend Go ที่ถูกสร้าง ให้ส่งออกรหัสต้นฉบับและโปรไฟล์แบบเดียวกัน โค้ดที่ถูกสร้างก็ยังเป็นโค้ด Go และ pprof จะชี้ไปที่ฟังก์ชันและบรรทัดจริง

ถ้าความต้องการเปลี่ยนบ่อย AppMaster (appmaster.io) อาจเป็นวิธีปฏิบัติที่จะสร้างและสร้าง backend Go ที่สะอาดเมื่อแอปพัฒนา แล้วโปรไฟล์โค้ดที่ส่งออกภายใต้โหลดที่สมจริงก่อนปล่อย

คำถามที่พบบ่อย

ทำไมการพุ่งของทราฟฟิกถึงทำให้หน่วยความจำกระโดดทั้งที่โค้ดไม่ได้เปลี่ยน?

การพุ่งของทราฟฟิกมักทำให้อัตราการจัดสรรหน่วยความจำเพิ่มขึ้นกว่าที่คิด แม้ว่าจะเป็นออบเจ็กต์ชั่วคราวขนาดเล็ก แต่เมื่อคูณด้วย RPS การเพิ่มขึ้นจะชัดเจน ซึ่งจะเร่งการทำงานของ GC และผลัก RSS ให้ขึ้นแม้ว่า heap ที่ใช้จริงจะไม่มากนัก

ทำไม RSS ถึงเพิ่มทั้งที่ Go heap ดูนิ่ง?

เมตริก heap ติดตามหน่วยความจำที่ Go จัดการ แต่ RSS ครอบคลุมมากกว่า: สแต็กของ goroutine ข้อมูล runtime แผนที่ของ OS การแตกชิ้น (fragmentation) และการจัดสรรนอก heap (เช่น บางการใช้งาน cgo) จึงปกติที่ RSS กับ heap จะเคลื่อนไหวต่างกันระหว่างสไปก ให้ใช้ pprof เพื่อตามหาจุดที่มีการจัดสรรในโค้ด Go มากกว่าพยายามทำให้ตัวเลข RSS ตรงกันเป๊ะ

ควรดู heap หรือ alloc profile ก่อนในช่วงสไปก?

ถ้าคิดว่าอะไรยังอยู่ในหน่วยความจำ ให้เริ่มจาก heap profile ส่วนถ้าคิดว่ามี churn (ออบเจ็กต์ชั่วคราวเยอะ) ให้ดู profile แบบโฟกัสการจัดสรร เช่น allocs/alloc_space ในช่วงสไปก มักเป็นกรณีของ churn ที่ทำให้ GC ใช้ CPU มากและเพิ่ม latency ในหาง

วิธีที่ปลอดภัยที่สุดในการเปิดเผย pprof ในโปรดักชันคืออะไร?

การตั้งค่าแบบปลอดภัยที่สุดคือรัน pprof บนเซิร์ฟเวอร์แอดมินแยกต่างหาก ผูกกับ 127.0.0.1 และเปิดเข้าถึงเฉพาะจากเครือข่ายภายในหรือผ่านช่องทางที่ปลอดภัย ถือ pprof เป็นอินเทอร์เฟซแอดมินเพราะมันเปิดเผยรายละเอียดภายในของบริการ

ฉันควรจับกี่โปรไฟล์ และเมื่อไร?

จับเวลาแบบสั้นๆ: หนึ่งโปรไฟล์ก่อนสไปก (baseline) หนึ่งระหว่างสไปก (impact) และหนึ่งหลังสไปก (recovery) วิธีนี้ช่วยให้แยกการเปลี่ยนแปลงจริงจากพฤติกรรมปกติได้ง่ายขึ้น

ความแตกต่างระหว่าง inuse และ alloc_space ใน pprof คืออะไร?

ใช้ inuse เพื่อหาสิ่งที่ยังถูกอ้างอิงอยู่ในช่วงเวลาจับภาพ และใช้ alloc_space เพื่อดูสิ่งที่ถูกสร้างอย่างหนัก การสับสนระหว่างสองมุมมองนี้เป็นข้อผิดพลาดทั่วไป: churn มากแต่ inuse ต่ำก็ยังเป็นปัญหาได้

วิธีที่เร็วที่สุดในการลดการจัดสรรที่เกี่ยวข้องกับ JSON คืออะไร?

ถ้า encoding/json เป็นจุดร้อน ส่วนใหญ่เป็นเรื่องโครงสร้างข้อมูลที่ป้อนเข้าให้มัน ไม่ใช่แพ็กเกจเอง ให้เปลี่ยน map[string]any เป็น struct ที่มีชนิดชัดเจน หลีกเลี่ยง json.MarshalIndent ในโปรดักชัน และอย่าสร้างสตริงชั่วคราวมากเกินไป การเปลี่ยนแปลงเหล่านี้มักลดการจัดสรรได้ทันที

ทำไมการสแกนคำสั่งฐานข้อมูลถึงทำให้หน่วยความจำพุ่งในช่วงสไปก?

การสแกนแถวลงเป้าหมายยืดหยุ่นอย่าง interface{} หรือ map[string]any การแปลง []byte เป็น string สำหรับฟิลด์จำนวนมาก และการดึงแถว/คอลัมน์เยอะเกินความจำเป็น จะสร้างการจัดสรรต่อคำขอจำนวนมาก เลือกคอลัมน์ที่ต้องการ แบ่งหน้า หรือสแกนตรงลง struct ที่มีชนิดคงที่เพื่อแก้ปัญหาได้เร็ว

แพตเทิร์น middleware แบบใดมักทำให้เกิดการจัดสรรแบบ “death by a thousand cuts”?

middleware ทำงานกับทุกคำขอ จึงแม้การจัดสรรเล็กๆ จะสะสมจนใหญ่ได้ ตัวอย่างเช่น การสร้างสตริงสำหรับล็อก การสร้าง label สูง ๆ สำหรับ tracing การสร้าง gzip reader/writer ใหม่ทุกครั้ง หรือการเก็บ struct ใหม่ลงใน context ล้วนเป็นต้นเหตุของ churn ที่เห็นเป็น steady allocation ในโปรไฟล์

ฉันใช้ workflow pprof นี้กับ Go backend ที่สร้างโดย AppMaster ได้ไหม?

ใช่ ทุกแนวทางที่ขับเคลื่อนการโปรไฟล์ใช้ได้กับโค้ด Go ที่ถูกสร้างโดย AppMaster ถ้าคุณส่งออกรหัส backend ที่สร้าง คุณก็สามารถรัน pprof หาจุดที่มีการจัดสรร และปรับโมเดล ตัวจัดการ และตรรกะข้ามชั้นเพื่อลดการจัดสรรต่อคำขอก่อนเกิดสไปกครั้งต่อไป

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม