Go: worker pool vs goroutine ต่องาน สำหรับงานแบ็กกราวด์
เปรียบเทียบ worker pool กับ goroutine ต่องาน ใน Go: เรียนรู้ว่าแต่ละโมเดลส่งผลต่อ throughput, การใช้หน่วยความจำ และการควบคุม backpressure สำหรับการประมวลผลแบ็กกราวด์และเวิร์กโฟลว์ที่รันนานอย่างไร

เรากำลังแก้ปัญหาอะไรอยู่?
บริการ Go ส่วนใหญ่ไม่ได้มีไว้แค่ตอบ HTTP เท่านั้น พวกมันยังรันงานแบ็กกราวด์: ส่งอีเมล ย่อรูป สร้างบิล ซิงค์ข้อมูล ประมวลผลอีเวนต์ หรือสร้างดัชนีค้นหา งานบางอย่างเร็วและเป็นอิสระ อีกงานเป็นเวิร์กโฟลว์ยาวที่แต่ละขั้นตอนขึ้นกับขั้นก่อนหน้า (หักบัตร รอการยืนยัน แล้วแจ้งลูกค้าและอัปเดตรายงาน)
เมื่อคนพูดถึง "Go worker pools vs goroutine-per-task" พวกเขามักแก้ปัญหาโปรดักชันปัญหาเดียว: จะรันงานแบ็กกราวด์จำนวนมากอย่างไรโดยไม่ทำให้บริการช้า แพง หรือไม่เสถียร
ผลกระทบจะแสดงในหลายที่:
- Latency: งานแบ็กกราวด์แย่ง CPU หน่วยความจำ การเชื่อมต่อฐานข้อมูล และแบนด์วิดท์เครือข่ายจากคำขอที่เห็นโดยผู้ใช้
- ต้นทุน: การขนานที่ไม่ถูกควบคุมจะผลักดันให้ต้องใช้เครื่องใหญ่ขึ้น ความจุฐานข้อมูลมากขึ้น หรือค่าใช้จ่ายคิวและ API สูงขึ้น
- ความเสถียร: สไปก์ (การนำเข้าข้อมูล, ส่งการตลาด, พายุ retry) อาจกระตุ้น timeout, การชนของ OOM, หรือความล้มเหลวแบบลุกลาม
ข้อแลกเปลี่ยนที่แท้จริงคือ ความเรียบง่ายกับการควบคุม การสั่งให้ goroutine ต่อ task เขียนง่ายและมักโอเคเมื่อปริมาณน้อยหรือถูกจำกัดโดยธรรมชาติ พูลงานเพิ่มโครงสร้าง: ขีดจำกัดการขนานที่ตายตัว ขอบเขตชัดเจน และที่วางธรรมชาติสำหรับวาง timeout, retry และเมตริก ราคาที่ต้องจ่ายคือโค้ดเพิ่มขึ้นและต้องตัดสินใจว่าทำอย่างไรเมื่อระบบยุ่ง (งานรอ ปฏิเสธ หรือเก็บไว้ที่อื่น?)
โพสต์นี้เกี่ยวกับการประมวลผลแบ็กกราวด์ในชีวิตประจำวัน: throughput, หน่วยความจำ และ backpressure (วิธีป้องกันไม่ให้ระบบล้น) มันไม่ได้ครอบคลุมเทคโนโลยีคิวทั้งหมด เครื่องยนต์เวิร์กโฟลว์แบบกระจาย หรือรูปแบบ exactly-once
ถ้าคุณกำลังสร้างแอปเต็มรูปแบบที่มีตรรกะแบ็กกราวด์ด้วยแพลตฟอร์มอย่าง AppMaster (appmaster.io) คำถามเดิม ๆ จะโผล่มาเร็ว ๆ กระบวนการทางธุรกิจและการเชื่อมต่อยังต้องมีขอบเขตรอบฐานข้อมูล, API ภายนอก และผู้ให้บริการอีเมล/SMS เพื่อไม่ให้เวิร์กโฟลว์ที่ยุ่งหนึ่งชะลอทุกอย่าง
สองรูปแบบที่พบบ่อยอธิบายง่าย ๆ
Goroutine-per-task
นี่คือวิธีที่ง่ายที่สุด: เมื่อมีงานเข้ามา ให้เริ่ม goroutine เพื่อจัดการมัน "คิว" มักเป็นสิ่งที่กระตุ้นงาน เช่น ตัวรับจาก channel หรือการเรียกตรงจาก HTTP handler
รูปแบบทั่วไปคือ: รับงาน แล้ว go handle(job) บางครั้งยังมี channel เป็นจุดส่งต่อ แต่ไม่ใช่ตัวจำกัด
วิธีนี้มักทำงานได้ดีเมื่องานส่วนใหญ่รอ I/O (เรียก HTTP, คิวรี DB, อัปโหลด), ปริมาณงานไม่มาก และสไปก์เล็กหรือคาดการณ์ได้
ข้อเสียคือการขนานอาจเติบโตโดยไม่มีเพดานชัดเจน ซึ่งอาจทำให้หน่วยความจำพุ่ง เปิดการเชื่อมต่อมากเกินไป หรือทำให้บริการภายนอกล่ม
Worker pool
Worker pool จะเริ่ม worker goroutine จำนวนคงที่แล้วส่งงานให้พวกมันจากคิว มักเป็น buffered channel ภายในโปรเซส แต่ละ worker จะวน: เอางาน, ประมวลผล, ทำซ้ำ
ความแตกต่างสำคัญคือการควบคุม จำนวน worker เป็นเพดานการขนานที่ชัดเจน ถ้างานมาถึงเร็วกว่าที่ worker จะทำเสร็จ งานจะรอในคิว (หรือถูกปฏิเสธถ้าคิวเต็ม)
Worker pool เหมาะเมื่องานหนักที่ใช้ CPU (เช่น การประมวลผลรูป, สร้างรายงาน), เมื่อคุณต้องการการใช้ทรัพยากรที่คาดการณ์ได้, หรือเมื่อต้องปกป้องฐานข้อมูลหรือ API ภายนอกจากสไปก์
คิวอยู่ตรงไหน
ทั้งสองรูปแบบสามารถใช้ channel ในหน่วยความจำได้ ซึ่งเร็วแต่หายไปเมื่อรีสตาร์ท สำหรับงานที่ "ต้องไม่หาย" หรือเวิร์กโฟลว์ยาว ๆ คิวมักย้ายออกนอกโปรเซส (ตารางฐานข้อมูล, Redis, หรือตัวกลางข้อความ) ในการตั้งค่านั้น คุณยังเลือกได้ระหว่าง goroutine-per-task และ worker pool แต่ตอนนี้พวกมันรันเป็น consumer ของคิวภายนอก
ตัวอย่างง่าย ๆ: ถ้าระบบต้องส่ง 10,000 อีเมลพร้อมกัน goroutine-per-task อาจพยายามยิงทั้งหมดในคราวเดียว ในขณะที่พูลอาจส่งทีละ 50 และปล่อยให้อื่น ๆ รออย่างมีการควบคุม
Throughput: อะไรเปลี่ยนและอะไรไม่เปลี่ยน
คนมักคาดหวังความแตกต่างมากระหว่าง worker pool กับ goroutine-per-task แต่ส่วนใหญ่ throughput ถูกจำกัดโดยอย่างอื่น ไม่ใช่วิธีเริ่ม goroutine
Throughput มักถึงเพดานที่ทรัพยากรร่วมช้าที่สุด: ฐานข้อมูลหรือข้อจำกัดของ API ภายนอก, ดิสก์หรือแบนด์วิดท์เครือข่าย, งานหนัก CPU (JSON/PDF/ย่อรูป), ล็อกและสถานะที่แชร์, หรือบริการปลายทางที่ช้าลงเมื่อโหลด
ถ้าทรัพยากรร่วมเป็นคอขวด การสร้าง goroutine มากขึ้นจะไม่ทำให้งานเสร็จเร็วขึ้น ส่วนใหญ่เป็นการเพิ่มระยะเวลารอที่จุดคับคั่งเดียวกัน
goroutine-per-task ชนะเมื่อแต่ละงานสั้น เป็น I/O-bound ส่วนใหญ่ และไม่แย่งกันใช้ข้อจำกัดร่วม การเริ่ม goroutine ถูกและ Go สามารถตารางการทำงานของจำนวนมากได้ดี ในรูปแบบ "fetch, parse, write one row" นี้จะช่วยให้ CPU ทำงานต่อเนื่องและซ่อน latency ของเครือข่าย
Worker pools ชนะเมื่อคุณต้องจำกัดทรัพยากรที่แพง หากแต่ละงานถือการเชื่อมต่อ DB, เปิดไฟล์, จัดสรรบัฟเฟอร์ขนาดใหญ่, หรือกระทบโควต้าของ API การขนานคงที่จะทำให้บริการเสถียรในขณะที่ยังไปถึง throughput สูงสุดที่ปลอดภัย
Latency (โดยเฉพาะ p99) มักเป็นที่ที่ต่างกันเห็นได้ชัด goroutine-per-task อาจดูดีที่โหลดต่ำ แล้วพังเมื่อมีงานกอง พูลสร้างความหน่วงจากคิว แต่มันทำให้พฤติกรรมคงที่กว่าเพราะป้องกันฝูงชนไล่แย่งทรัพยากรเดียวกัน
แบบจำลองง่าย ๆ ในหัว:
- ถ้างานถูกและเป็นอิสระ การขนานมากขึ้นสามารถเพิ่ม throughput
- ถ้างานถูกชี้ด้วยข้อจำกัดร่วม การขนานมากขึ้นส่วนใหญ่เพิ่มการรอ
- ถ้าคุณสนใจ p99 ให้วัดเวลาในคิวแยกจากเวลาประมวลผล
การใช้หน่วยความจำและทรัพยากร
ส่วนใหญ่ของการถกเถียง worker-pool vs goroutine-per-task จริง ๆ คือเรื่องหน่วยความจำ CPU มักขยายขึ้นหรือลงได้ หน่วยความจำล้มเหลวอย่างฉับพลันและอาจทำให้บริการทั้งหมดลงได้
goroutine ถูกแต่ไม่ฟรี แต่ละตัวเริ่มด้วยสแตกเล็ก ๆ ที่โตขึ้นเมื่อเรียกฟังก์ชันลึกขึ้นหรือถือ local variable ขนาดใหญ่ นอกจากนี้ยังมีต้นทุนการบันทึกสถานะของ scheduler และ runtime สิบพัน goroutine อาจโอเค แต่แสนอาจทำให้ตกใจถ้าแต่ละตัวอ้างอิงข้อมูลงานขนาดใหญ่
ต้นทุนที่ซ่อนอยู่บ่อยครั้งไม่ใช่ตัว goroutine เอง แต่สิ่งที่มันเก็บไว้ ถ้างานมาถึงเร็วกว่าที่เสร็จ goroutine-per-task จะสร้าง backlog ไม่จำกัด "คิว" อาจเป็นแบบแฝง (goroutine รอ lock หรือ I/O) หรือชัดเจน (buffered channel, slice, batch ในหน่วยความจำ) อย่างไรก็ดีหน่วยความจำจะเติบโตตาม backlog
Worker pools ช่วยเพราะบังคับเพดาน ด้วย worker คงที่และคิวที่จำกัด คุณได้ขีดจำกัดหน่วยความจำจริงและโหมดล้มเหลวที่ชัดเจน: เมื่อคิวเต็ม คุณบล็อก ทิ้งภาระ หรือผลักกลับขึ้นต้นทาง
การคำนวณคร่าว ๆ:
- peak goroutines = workers + งานที่กำลังทำ + งานที่รอที่คุณสร้างขึ้น
- หน่วยความจำต่องาน = payload (ไบต์) + เมตาดาต้า + สิ่งที่อ้างอิง (requests, JSON ที่ decode แล้ว, แถว DB)
- หน่วยความจำ backlog สูงสุด ~= งานที่รอ * หน่วยความจำต่องาน
ตัวอย่าง: ถ้าแต่ละงานถือ payload ขนาด 200 KB และคุณปล่อยให้ 5,000 งานสะสม นั่นประมาณ 1 GB สำหรับ payload เท่านั้น ถึงแม้ goroutine จะฟรี แต่ backlog ก็ไม่ฟรี
Backpressure: ป้องกันระบบไม่ให้ละลาย
Backpressure ง่าย ๆ คือ: เมื่องานมามากกว่าที่จะทำ ให้ระบบผลักกลับอย่างควบคุมได้แทนที่จะเงียบ ๆ สะสม ถ้าไม่มีมัน คุณไม่แค่ช้าลง คุณจะเจอ timeout, การเพิ่มของหน่วยความจำ, และความล้มเหลวที่ยากจะทำซ้ำ
คุณมักสังเกตเห็นการขาด backpressure เมื่อสไปก์ทำให้รูปแบบแบบนี้ปรากฏ: หน่วยความจำขึ้นและไม่ลด, เวลาในคิวเพิ่มในขณะที่ CPU ยังคงใช้งาน, latency พุ่งสำหรับคำขอที่ไม่เกี่ยวข้อง, retry สะสม, หรือข้อผิดพลาดเช่น "too many open files" และ pool การเชื่อมต่อหมด
เครื่องมือภาคปฏิบัติคือ channel ที่จำกัดขนาด: กำหนดขอบเขตว่างานกี่งานรอได้ ผู้ผลิตจะบล็อกเมื่อช่องเต็ม ซึ่งจะชะลอการสร้างงานที่ต้นทาง
การบล็อกไม่ใช่คำตอบเสมอไป สำหรับงานที่ไม่สำคัญ ให้เลือกนโยบายที่ชัดเจนเพื่อให้การโอเวอร์โหลดคาดเดาได้:
- ทิ้ง งานค่าต่ำ (เช่น การแจ้งเตือนซ้ำ)
- รวมเป็นแบตช์ หลายงานเล็กเป็นการเขียนหรือการเรียก API หนึ่งครั้ง
- หน่วงเวลา งานพร้อม jitter เพื่อหลีกเลี่ยงพายุ retry
- ย้ายไปคิวถาวร แล้วคืนเร็ว
- ทิ้งโหลด คืนข้อผิดพลาดที่ชัดเจนเมื่อระบบอัดแน่น
Rate limiting และ timeout ก็เป็นเครื่องมือ backpressure ด้วย Rate limiting จำกัดความเร็วที่คุณชน dependency (ผู้ให้บริการอีเมล, DB, API ภายนอก) Timeouts จำกัดระยะเวลาที่ worker จะติดอยู่ ทั้งสองช่วยหยุดไม่ให้ dependency ช้ากลายเป็น outage ทั้งระบบ
ตัวอย่าง: การสร้าง statement ประจำเดือน ถ้า 10,000 คำขอเข้ามาพร้อมกัน goroutine ไม่จำกัดอาจกระตุ้นการเรนเดอร์ PDF และอัปโหลด 10,000 ชุด ด้วยคิวและ worker คงที่ คุณเรนเดอร์และลองใหม่ในอัตราที่ปลอดภัย
วิธีสร้าง worker pool ทีละขั้น
Worker pool จำกัดการขนานโดยรัน worker จำนวนคงที่และส่งงานให้จากคิว
1) เลือกขีดจำกัดการขนานที่ปลอดภัย
เริ่มจากสิ่งที่งานใช้เวลาอยู่
- สำหรับงานหนัก CPU ให้ตั้ง worker ใกล้เคียงจำนวนคอร์ของ CPU
- สำหรับงาน I/O (DB, HTTP, storage) คุณสามารถตั้งสูงกว่าได้ แต่หยุดเมื่อ dependency เริ่ม timeout หรือ throttle
- สำหรับงานผสม ให้วัดและปรับ ค่าที่เริ่มได้มักอยู่ระหว่าง 2x ถึง 10x ของคอร์ CPU แล้วค่อย tune
- เคารพข้อจำกัดร่วม เช่น ถ้า DB pool มี 20 การเชื่อมต่อ การมี worker 200 จะทำให้แย่งกันแค่ 20
2) เลือกคิวและตั้งขนาด
buffered channel เป็นสิ่งที่พบบ่อยเพราะมีมาให้ในภาษาและเข้าใจง่าย บัฟเฟอร์คือช็อกแอบซอร์เบอร์สำหรับสไปก์
บัฟเฟอร์เล็กทำให้เห็นการโอเวอร์โหลดเร็วกว่า (ผู้ส่งบล็อกเร็วกว่า) บัฟเฟอร์ใหญ่เกลี่ยสไปก์ได้แต่ซ่อนปัญหาและเพิ่มหน่วยความจำและ latency กำหนดขนาดบัฟเฟอร์อย่างมีจุดมุ่งหมายและตัดสินใจว่าจะทำอย่างไรเมื่อมันเต็ม
3) ทำให้ทุกงานยกเลิกได้
ส่ง context.Context เข้ากับแต่ละงานและให้โค้ดงานเคารพมัน (DB, HTTP) นี่คือวิธีหยุดสะอาดเมื่อ deploy, shutdown, หรือเกิด timeout
func StartPool(ctx context.Context, workers, queueSize int, handle func(context.Context, Job) error) chan<- Job {
jobs := make(chan Job, queueSize)
for i := 0; i < workers; i++ {
go func() {
for {
select {
case <-ctx.Done():
return
case j := <-jobs:
_ = handle(ctx, j)
}
}
}()
}
return jobs
}
4) เพิ่มเมตริกที่คุณจะใช้จริง
ถ้าคุณติดตามแค่ไม่กี่ตัว ให้มีตัวเหล่านี้:
- ความลึกของคิว (queue depth)
- เวลาที่ worker ยุ่ง (worker busy time) / ความอิ่มตัวของพูล
- ระยะเวลางาน (p50, p95, p99)
- อัตราข้อผิดพลาด (และจำนวน retry หากคุณลองใหม่)
แค่นี้พอให้ปรับจำนวน worker และขนาดคิวโดยมีหลักฐาน ไม่ใช่การเดา
ข้อผิดพลาดและกับดักที่พบบ่อย
ทีมส่วนใหญ่ไม่เจ็บจากการเลือกรูปแบบผิด พวกเขาเจ็บจากค่าเริ่มต้นเล็ก ๆ ที่กลายเป็นเหตุการณ์เมื่อทราฟฟิกพุ่ง
เมื่อ goroutine เพิ่มจำนวนขึ้น
กับดักคลาสสิกคือการสปอนส์ goroutine ต่อ job ในช่วงสไปก์ ร้อยสองร้อยโอเค แต่หลายแสนอาจทำให้ scheduler, heap, logs และซ็อกเก็ตเครือข่ายล้น ถึงแม้แต่ละ goroutine จะเล็ก แต่ต้นทุนรวมก็สะสม และการกู้คืนต้องใช้เวลาเพราะงานอยู่ในสถานะกำลังทำอยู่แล้ว
ความผิดอีกประการคือการถือว่าช่องขนาดใหญ่เป็น "backpressure" ช่องใหญ่เป็นแค่คิวที่ซ่อนอยู่ มันอาจซื้อเวลา แต่ก็ซ่อนปัญหาจนกว่าจะชนกำแพงหน่วยความจำ ถ้าคุณต้องการคิว ให้กำหนดขนาดอย่างตั้งใจและตัดสินใจว่าจะทำอะไรเมื่อมันเต็ม (บล็อก ทิ้ง ลองใหม่ภายหลัง หรือลงเก็บถาวร)
คอขวดที่ซ่อนอยู่
งานแบ็กกราวด์หลายอย่างไม่ผูกกับ CPU แต่ถูกจำกัดโดยสิ่งที่อยู่ด้านล่าง ถ้าคุณละเลยข้อจำกัดเหล่านั้น ผู้ผลิตที่เร็วจะท่วมผู้บริโภคที่ช้า
กับดักทั่วไป:
- ไม่มีการยกเลิกหรือ timeout ทำให้ worker ติดอยู่กับการเรียก API หรือ DB นาน ๆ
- เลือกจำนวน worker โดยไม่ตรวจข้อจำกัดจริงเช่น การเชื่อมต่อ DB, I/O ดิสก์, หรือโควต้าของ third-party
- การลองใหม่ที่เพิ่มโหลด (retry ทันทีใน 1,000 งานที่ล้มเหลว)
- ล็อกร่วมหรือธุรกรรมที่ทำให้ทุกอย่างเป็นลำดับ ทำให้ "worker มากขึ้น" เป็นการเพิ่ม overhead เท่านั้น
- ขาดความโปร่งใส: ไม่มีเมตริกสำหรับความลึกของคิว, อายุของงาน, จำนวน retry, และการใช้งาน worker
ตัวอย่าง: การส่งออกตอนกลางคืนกระตุ้น 20,000 งาน "ส่งการแจ้งเตือน" ถ้าทุกงานติดต่อ DB และผู้ให้บริการอีเมล คุณอาจเกิน pool การเชื่อมต่อหรือโควต้าได้ง่าย ๆ พูล 50 worker พร้อม timeout ต่องานและคิวเล็ก ๆ ทำให้ข้อจำกัดชัดเจน ส่วนการใช้ goroutine ต่อ task พร้อมบัฟเฟอร์ใหญ่ทำให้ระบบดูดีจนกว่าจะพังทันที
ตัวอย่าง: การส่งออกและการแจ้งเตือนที่เป็นสไปก์
นึกภาพทีมซัพพอร์ตต้องการข้อมูลเพื่อตรวจสอบ คนหนึ่งคลิกปุ่ม "Export" แล้วเพื่อนร่วมงานอีกสองสามคนทำแบบเดียวกัน ทันใดนั้นมีงานส่งออก 5,000 งานภายในหนึ่งนาที แต่ละงานอ่านจาก DB สร้าง CSV เก็บไฟล์ และส่งการแจ้งเตือนเมื่อเสร็จ
กับวิธี goroutine-per-task ระบบดูดีชั่วครู่ งานทั้ง 5,000 เริ่มเกือบพร้อมกันและดูเหมือนคิวลดเร็ว แต่ค่าใช้จ่ายจะตามมา: การคิวรี DB พร้อมกันจำนวนมากแข่งกันใช้การเชื่อมต่อ, หน่วยความจำเพิ่มเมื่อแต่ละงานถือบัฟเฟอร์, และ timeout เกิดบ่อย งานที่ควรเสร็จเร็วกลับต้องรอเพราะ retry และ query ช้า
กับ worker pool การเริ่มช้ากว่าแต่การรันโดยรวมสงบกว่า ด้วย 50 worker จะมีการทำงานหนักพร้อมกันแค่ 50 งาน การใช้ฐานข้อมูลคงในช่วงที่คาดการณ์ได้ บัฟเฟอร์ถูกใช้ซ้ำและ latency สม่ำเสมอ เวลาเสร็จโดยรวมประเมินได้ง่ายขึ้น: ประมาณ (งาน / worker) * เวลางานเฉลี่ย บวกโอเวอร์เฮดเล็กน้อย
ความต่างสำคัญไม่ใช่ว่าพูลเร็ววิเศษ แต่มันหยุดระบบจากการทำร้ายตัวเองในช่วงสไปก์ การรันแบบควบคุมทีละ 50 มักทำให้เสร็จเร็วกว่าการปล่อย 5,000 งานให้แย่งกัน
ตำแหน่งที่คุณใช้ backpressure ขึ้นกับสิ่งที่ต้องการปกป้อง:
- ชั้น API: ปฏิเสธหรือหน่วงคำขอส่งออกใหม่เมื่อระบบยุ่ง
- ที่คิว: รับคำขอแต่ enqueue แล้วระบายด้วยอัตราปลอดภัย
- ใน worker pool: จำกัดการขนานสำหรับส่วนที่แพง (อ่าน DB, สร้างไฟล์, ส่งการแจ้งเตือน)
- แยกตามทรัพยากร: ตั้งขีดจำกัดแยกกัน (เช่น 40 worker สำหรับการส่งออก แต่ 10 สำหรับการแจ้งเตือน)
- การเรียกภายนอก: rate-limit อีเมล/SMS/Telegram เพื่อไม่ให้ถูกบล็อก
เช็คลิสต์ด่วนก่อนปล่อยขึ้นโปรดักชัน
ก่อนรันงานแบ็กกราวด์ในโปรดักชัน ให้ตรวจขอบเขต ความโปร่งใส และการจัดการความล้มเหลว ส่วนใหญ่เหตุการณ์ไม่ใช่เพราะโค้ดช้า แต่เกิดจากการขาดรั้วป้องกันเมื่อโหลดพุ่งหรือ dependency ผิดปกติ
- ตั้งเพดานสูงสุดการขนานต่อ dependency. อย่าเลือกเลขเดียวแล้วหวังว่าจะพอดีสำหรับทุกอย่าง จำกัดการเขียน DB, การเรียก HTTP ภายนอก, และงานหนัก CPU แยกกัน
- ทำให้คิวมีขนาดจำกัดและสังเกตได้. ใส่ขอบเขตจริงบนงานที่รอและเปิดเมตริกบางตัว: ความลึกของคิว, อายุของงานเก่าที่สุด, และอัตราการประมวลผล
- เพิ่ม retry พร้อม jitter และเส้นทาง dead-letter. ลองใหม่แบบเลือกได้ กระจาย retry และหลัง N ครั้งย้ายงานไปที่ dead-letter queue หรือตาราง "failed" พร้อมรายละเอียดพอให้ตรวจและรันซ้ำ
- ยืนยันพฤติกรรมเมื่อลง: ระบาย ยกเลิก และ resume อย่างปลอดภัย ตัดสินใจว่าทำอย่างไรเมื่อ deploy หรือ crash ทำให้งาน idempotent เพื่อให้ปลอดภัยเมื่อต้องประมวลผลซ้ำ และเก็บความคืบหน้าสำหรับเวิร์กโฟลว์ยาว
- ปกป้องระบบด้วย timeout และ circuit breaker. การเรียกภายนอกทุกครั้งต้องมี timeout ถ้า dependency ล่ม ให้ล้มเร็ว (หรือหยุดรับเข้า) แทนที่จะสะสมงาน
ขั้นตอนปฏิบัติถัดไป
เลือกรูปแบบที่ตรงกับสภาพระบบในวันที่เป็นปกติ ไม่ใช่วันที่สมบูรณ์แบบ ถ้าผลงานมาถึงเป็นสไปก์ (อัปโหลด, ส่งออก, แคมเปญอีเมล) พูลเวิร์กเกอร์คงที่พร้อมคิวจำกัดมักเป็นค่าเริ่มต้นที่ปลอดภัยกว่า ถ้าผลงานสม่ำเสมอและแต่ละงานเล็ก goroutine-per-task ก็อาจพอได้ ตราบใดที่คุณยังบังคับขีดจำกัดที่ไหนสักแห่ง
ตัวเลือกที่ชนะมักเป็นอันที่ทำให้การล้มเหลวไม่น่าตื่นเต้น Pools ทำให้ขอบเขตชัดเจน goroutine-per-task ทำให้ง่ายลืมขอบเขตจนกว่าจะเกิดสไปก์แรก
เริ่มง่าย แล้วเพิ่มขอบเขตและความโปร่งใส
เริ่มจากสิ่งง่าย ๆ แต่เพิ่มสองการควบคุมตั้งแต่แรก: ขีดจำกัดการขนานและวิธีดูคิวและความล้มเหลว
แผนการปล่อยใช้งานจริง:
- กำหนดรูปแบบงานของคุณ: เป็นสไปก์ สม่ำเสมอ หรือผสม (และ "พีค" ดูเป็นอย่างไร)
- ตั้งเพดานการทำงานในหน่วยความจำ (ขนาดพูล, เซมาฟอร์, หรือช่องทางที่จำกัด)
- ตัดสินใจว่าจะทำอย่างไรเมื่อถึงเพดาน: บล็อก ทิ้ง หรือคืนข้อผิดพลาดชัดเจน
- เพิ่มเมตริกพื้นฐาน: ความลึกของคิว, เวลาในคิว, เวลาประมวลผล, retry, และ dead letters
- ทดสอบโหลดด้วยสไปก์ที่เป็น 5x ของพีคที่คาดไว้และดูหน่วยความจำและ latency
เมื่อพูลยังไม่พอ
ถ้าเวิร์กโฟลว์รันเป็นนาทีถึงวัน พูลเรียบง่ายอาจไม่พอเพราะงานไม่ใช่แค่ "ทำครั้งเดียว" คุณต้องการสถานะ, retry, และการ resume นั่นมักหมายถึงการเก็บความคืบหน้า, ใช้ขั้นตอนที่ idempotent, และใช้ backoff นอกจากนี้อาจต้องแยกงานใหญ่เป็นขั้นตอนย่อยเพื่อให้ resume ได้ปลอดภัย
ถ้าคุณต้องการส่งแบ็กเอนด์เต็มรูปแบบที่มีเวิร์กโฟลว์เร็วขึ้น AppMaster (appmaster.io) อาจเป็นตัวเลือกที่ใช้งานได้: คุณม็อดเดลข้อมูลและตรรกะธุรกิจแบบภาพ แล้วระบบจะสร้างโค้ด Go จริงสำหรับแบ็กเอนด์ ช่วยให้คุณรักษาวินัยรอบข้อจำกัดการขนาน การคิว และ backpressure โดยไม่ต้องเดินสายทุกอย่างเอง
คำถามที่พบบ่อย
ปกติให้เริ่มจาก worker pool เมื่อมีโอกาสที่งานจะมาพร้อมกันเป็นกลุ่ม หรือเมื่องานแตะขีดจำกัดร่วม เช่น จำนวนการเชื่อมต่อ DB, หน่วยประมวลผล, หรือโควต้าของ API ภายนอก ใช้ goroutine ต่อ task เมื่อปริมาณยังไม่มาก งานสั้น และคุณยังมีขีดจำกัดที่ชัดเจนอยู่ที่อื่น (เช่น เซมาฟอร์หรือ rate limiter)
การเริ่ม goroutine ต่อ task เขียนง่ายและมักให้ throughput ดีเมื่อโหลดต่ำ แต่สามารถทำให้เกิดคิวงานไม่จำกัดเมื่อมีสไปก์ได้ Worker pool จะเพิ่มเพดานการขนานแบบชัดเจน และเป็นที่วางสำหรับตั้ง timeout, retry และเมตริก ทำให้พฤติกรรมในโปรดักชันคาดเดาได้มากขึ้น
โดยทั่วไปไม่ต่างกันมาก ในระบบส่วนใหญ่ throughput ถูกจำกัดโดยคอขวดร่วม เช่น ฐานข้อมูล, API ภายนอก, I/O หรือขั้นตอนหนักของ CPU การเพิ่มจำนวน goroutine ไม่ได้แก้ปัญหาคอขวดเหล่านี้ แต่ทำให้เกิดการรอและการแย่งชิงทรัพยากรมากขึ้น
goroutine-per-task มักมี latency ดีกว่าเมื่อโหลดต่ำ แต่เมื่อโหลดสูงจะเลวร้ายขึ้นเพราะทุกอย่างแข่งกันใช้ทรัพยากร Pool อาจเพิ่มความหน่วงจากคิว แต่โดยรวมช่วยให้ p99 คงที่ขึ้นเพราะป้องกันภาวะ thundering herd บน dependency เดียวกัน
ต้นเหตุของ spike memory มาจาก backlog มากกว่าตัว goroutine เอง หากงานสะสมและแต่ละงานถือ payload ขนาดใหญ่ หน่วยความจำจะพุ่งอย่างรวดเร็ว Worker pool พร้อมคิวแบบจำกัดจะเปลี่ยนให้เป็นเพดานหน่วยความจำที่ชัดเจนและพฤติกรรมโอเวอร์โหลดที่คาดเดาได้
Backpressure คือการชะลอหรือหยุดรับงานใหม่เมื่อระบบกำลังติดแทนที่จะปล่อยให้คิวสะสมเงียบ ๆ ช่องทางง่าย ๆ ใน Go คือคิวแบบมีขนาดจำกัด: เมื่อเต็ม ผู้ส่งจะบล็อกหรือคุณคืนข้อผิดพลาด ซึ่งป้องกันการใช้งานหน่วยความจำและการเชื่อมต่อเกินพิกัด
เริ่มจากข้อจำกัดจริงของงาน สำหรับงานหนัก CPU ให้เริ่มใกล้กับจำนวนคอร์ของ CPU สำหรับงาน I/O คุณสามารถตั้งสูงกว่าได้ แต่หยุดเมื่อฐานข้อมูลหรือ API เริ่ม timeout หรือ throttle และอย่าลืมเคารพขนาด pool ของการเชื่อมต่อ
เลือกขนาดที่ดูดซับสไปก์ปกติแต่ไม่ปกปิดปัญหาเป็นเวลานาน บัฟเฟอร์เล็กจะทำให้เห็นการโอเวอร์โหลดเร็วกว่า บัฟเฟอร์ใหญ่ช่วยเกลี่ย แต่เพิ่มหน่วยความจำและทำให้ผู้ใช้รอนานก่อนจะเห็นความล้มเหลว ตัดสินใจก่อนว่าจะทำอย่างไรเมื่อคิวเต็ม: บล็อก ปฎิเสธ ลบทิ้ง หรือเก็บถาวร
ใช้ context.Context ต่อแต่ละงาน และให้การเรียก DB/HTTP เคารพมัน กำหนด timeout สำหรับการเรียกภายนอก และทำให้พฤติกรรมการปิดระบบชัดเจนเพื่อไม่ให้ worker จมอยู่กับการเรียกที่แขวน
ติดตามความลึกของคิว, เวลารอในคิว, ระยะเวลาของงาน (p50/p95/p99) และจำนวนข้อผิดพลาด/การลองใหม่ เมตริกเหล่านี้จะบอกว่าควรเพิ่ม worker ลดคิว เพิ่ม timeout หรือจำกัดอัตราการเรียกไปยัง dependency


