Worker pool trong Go so với goroutine cho mỗi tác vụ cho công việc nền
Worker pool trong Go vs goroutine cho mỗi tác vụ: tìm hiểu cách mỗi mô hình ảnh hưởng throughput, sử dụng bộ nhớ và backpressure cho xử lý nền và workflow dài.

Chúng ta đang giải quyết vấn đề gì?
Hầu hết dịch vụ Go không chỉ trả lời HTTP. Chúng còn chạy công việc nền: gửi email, thay đổi kích thước ảnh, tạo hóa đơn, đồng bộ dữ liệu, xử lý sự kiện, hoặc dựng lại chỉ mục tìm kiếm. Một số job nhanh và độc lập. Những job khác là các workflow dài nơi mỗi bước phụ thuộc vào bước trước (trừ tiền, chờ xác nhận, rồi thông báo khách hàng và cập nhật báo cáo).
Khi mọi người so sánh "Go worker pools vs goroutine-per-task", họ thường cố giải một vấn đề production: làm sao chạy nhiều công việc nền mà không làm dịch vụ chậm, tốn kém hoặc không ổn định.
Bạn sẽ thấy tác động ở vài nơi:
- Độ trễ: công việc nền lấy CPU, bộ nhớ, kết nối DB và băng thông mạng từ request phục vụ người dùng.
- Chi phí: đồng thời không kiểm soát đẩy bạn tới máy mạnh hơn, tăng dung lượng DB, hoặc hoá đơn queue và API cao hơn.
- Ổn định: các đợt bật (import, gửi marketing, storms retry) có thể kích hoạt timeout, crash OOM, hoặc lỗi dây chuyền.
Sự đánh đổi thực sự là đơn giản so với kiểm soát. Khởi một goroutine cho mỗi task thì dễ viết và thường ổn khi khối lượng thấp hoặc có giới hạn tự nhiên. Worker pool thêm cấu trúc: concurrency cố định, giới hạn rõ ràng, và chỗ hợp lý để đặt timeout, retry, và metrics. Giá phải trả là code nhiều hơn và phải quyết định điều gì xảy ra khi hệ thống bận (task chờ, bị từ chối, hay lưu chỗ khác?).
Bài này nói về xử lý nền hằng ngày: throughput, bộ nhớ, và backpressure (làm sao ngăn quá tải). Nó không cố gắng bao phủ mọi công nghệ queue, engine workflow phân tán, hay semantics exactly-once.
Nếu bạn xây app đầy đủ với logic nền bằng nền tảng như AppMaster, cùng những câu hỏi này xuất hiện nhanh. Quy trình kinh doanh và tích hợp vẫn cần giới hạn quanh DB, API bên ngoài, và nhà cung cấp email/SMS để một workflow bận không làm chậm mọi thứ khác.
Hai mẫu phổ biến nói đơn giản
Goroutine-per-task
Đây là cách đơn giản nhất: mỗi khi có job đến, khởi một goroutine để xử lý nó. “Queue” thường là thứ kích hoạt công việc, chẳng hạn receiver của channel hoặc một gọi trực tiếp từ handler HTTP.
Hình mẫu điển hình là: nhận job, rồi go handle(job). Đôi khi vẫn dùng channel, nhưng chỉ như điểm chuyển giao, không phải bộ giới hạn.
Nó hoạt động tốt khi job chờ nhiều I/O (gọi HTTP, truy vấn DB, upload), khối lượng vừa phải, và các đợt spike nhỏ hoặc có thể dự đoán.
Nhược điểm là concurrency có thể tăng không có giới hạn rõ ràng. Điều này có thể làm tăng đột ngột bộ nhớ, mở quá nhiều kết nối, hoặc quá tải dịch vụ hạ nguồn.
Worker pool
Worker pool khởi một số goroutine worker cố định và đẩy job cho chúng từ một hàng đợi, thường là channel có buffer trong bộ nhớ. Mỗi worker lặp: lấy job, xử lý, lặp lại.
Điểm khác biệt chính là kiểm soát. Số worker là giới hạn đồng thời cứng. Nếu job đến nhanh hơn worker xử lý, job chờ trong queue (hoặc bị từ chối nếu queue đầy).
Worker pool phù hợp khi công việc nặng CPU (xử lý ảnh, tạo báo cáo), khi bạn cần dùng tài nguyên dự đoán được, hoặc khi phải bảo vệ DB hay API bên thứ ba khỏi các đợt spike.
Hàng đợi nằm ở đâu
Cả hai pattern có thể dùng channel trong bộ nhớ, nhanh nhưng mất khi restart. Với job “không được mất” hoặc workflow dài, queue thường ra ngoài process (bảng DB, Redis, hoặc message broker). Trong setup đó, bạn vẫn chọn giữa goroutine-per-task và worker pool, nhưng giờ chúng chạy như consumer của queue ngoài.
Ví dụ đơn giản: nếu hệ thống cần gửi 10.000 email đột ngột, goroutine-per-task có thể cố gửi tất cả ngay lập tức. Pool có thể gửi 50 một lần và giữ phần còn lại chờ theo cách có kiểm soát.
Throughput: thay đổi gì và không thay đổi gì
Rất nhiều người mong khác biệt throughput lớn giữa worker pool và goroutine-per-task. Thường thì throughput thựс tế bị giới hạn bởi thứ khác, không phải cách bạn khởi goroutine.
Throughput thường đạt trần tại tài nguyên chia sẻ chậm nhất: DB hoặc API bên ngoài, băng thông đĩa hoặc mạng, công việc nặng CPU (JSON/PDF/xử lý ảnh), lock và trạng thái chia sẻ, hoặc dịch vụ hạ nguồn chậm khi chịu tải.
Nếu tài nguyên chia sẻ là nút thắt, khởi thêm goroutine không làm xong việc nhanh hơn. Nó chủ yếu tạo nhiều chờ đợi tại điểm nghẽn đó.
Goroutine-per-task có thể thắng khi tác vụ ngắn, phần lớn I/O, và không cạnh tranh trên giới hạn chia sẻ. Việc khởi goroutine rất rẻ, và Go lên lịch tốt cho số lượng lớn goroutine. Trong kiểu loop “fetch, parse, write một hàng”, cách này có thể giữ CPU bận và che lấp độ trễ mạng.
Worker pool thắng khi bạn cần giới hạn các tài nguyên đắt tiền. Nếu mỗi job giữ một kết nối DB, mở file, cấp phát buffer lớn, hoặc chạm hạn mức API, concurrency cố định giữ dịch vụ ổn định trong khi vẫn đạt throughput an toàn tối đa.
Độ trễ (nhất là p99) thường là nơi sự khác biệt xuất hiện. Goroutine-per-task có thể trông tốt ở tải thấp, rồi sụp đổ khi quá nhiều task xếp chồng. Pool tạo ra độ trễ do xếp hàng (job chờ worker rảnh), nhưng hành vi ổn định hơn vì tránh một đám đông cùng tranh tài nguyên.
Mô hình tư duy đơn giản:
- Nếu công việc rẻ và độc lập, tăng concurrency có thể tăng throughput.
- Nếu công việc bị chặn bởi giới hạn chia sẻ, tăng concurrency hầu như chỉ tăng thời gian chờ.
- Nếu bạn quan tâm tới p99, đo thời gian trong queue tách biệt với thời gian xử lý.
Bộ nhớ và sử dụng tài nguyên
Phần lớn tranh luận worker-pool vs goroutine-per-task thật ra là về bộ nhớ. CPU thường có thể scale lên hoặc ra. Lỗi bộ nhớ diễn ra đột ngột hơn và có thể làm rớt toàn bộ service.
Một goroutine rẻ nhưng không miễn phí. Mỗi goroutine khởi với stack nhỏ rồi lớn dần khi gọi sâu hoặc giữ biến local lớn. Còn có bookkeeping của scheduler và runtime. 10.000 goroutine có thể ổn. 100.000 có thể gây bất ngờ nếu mỗi cái giữ tham chiếu tới dữ liệu job lớn.
Chi phí ẩn lớn hơn thường không phải goroutine, mà là những gì nó giữ sống. Nếu task đến nhanh hơn khi kết thúc, goroutine-per-task tạo backlog không giới hạn. “Queue” có thể ngầm định (goroutine chờ lock hoặc I/O) hoặc rõ ràng (channel có buffer, slice, batch trong bộ nhớ). Dù cách nào, bộ nhớ tăng theo backlog.
Worker pool giúp vì nó ép một ngưỡng. Với worker cố định và queue có giới hạn, bạn có một giới hạn bộ nhớ thực tế và một chế độ thất bại rõ ràng: khi queue đầy, bạn block, shed load, hoặc đẩy lại upstream.
Tính nhanh ước lượng:
- Peak goroutines = workers + job đang chạy + job "chờ" bạn đã tạo
- Bộ nhớ cho mỗi job = payload (bytes) + metadata + bất cứ gì được tham chiếu (request, JSON đã giải mã, hàng DB)
- Bộ nhớ backlog đỉnh ~= số job chờ * bộ nhớ cho mỗi job
Ví dụ: nếu mỗi job giữ payload 200 KB và bạn để 5.000 job dồn, đó là khoảng 1 GB chỉ cho payload. Dù goroutine rẻ, backlog thì không.
Backpressure: giữ hệ thống không bị tan chảy
Backpressure đơn giản: khi công việc đến nhanh hơn khả năng hoàn thành, hệ thống đẩy lại có kiểm soát thay vì âm thầm chất đống. Không có nó, bạn không chỉ chậm hơn. Bạn gặp timeout, tăng bộ nhớ, và lỗi khó tái tạo.
Bạn thường nhận thấy thiếu backpressure khi một đợt spike khiến các pattern như bộ nhớ tăng và không giảm, thời gian chờ queue tăng trong khi CPU vẫn bận, latency cho request không liên quan tăng, retry dồn, hoặc lỗi như “too many open files” và cạn pool kết nối.
Công cụ thực tiễn là channel có giới hạn: giới hạn bao nhiêu job có thể chờ. Producer block khi channel đầy, điều này làm chậm sinh job ngay tại nguồn.
Block không luôn là lựa chọn đúng. Với công việc tuỳ chọn, chọn chính sách rõ ràng để quá tải có thể dự đoán:
- Drop các task giá trị thấp (ví dụ thông báo trùng lặp)
- Batch nhiều task nhỏ thành một lần ghi hoặc một gọi API
- Delay công việc với jitter để tránh retry spikes
- Defer sang queue bền và trả nhanh
- Shed load trả lỗi rõ ràng khi đã quá tải
Rate limiting và timeout cũng là công cụ backpressure. Rate limiting giới hạn tốc độ chạm phụ thuộc (email provider, DB, API). Timeout giới hạn thời gian worker có thể bị treo. Kết hợp chúng, bạn ngăn phụ thuộc chậm biến thành outage toàn hệ thống.
Ví dụ: sinh báo cáo tháng. Nếu 10.000 request tới cùng lúc, goroutine vô hạn có thể kích hoạt 10.000 render PDF và upload. Với queue có giới hạn và worker cố định, bạn render và retry ở tốc độ an toàn.
Cách xây worker pool từng bước
Worker pool giới hạn concurrency bằng cách chạy số worker cố định và đẩy job từ queue.
1) Chọn giới hạn concurrency an toàn
Bắt đầu từ chỗ job dành thời gian.
- Với công việc nặng CPU, giữ worker xấp xỉ số core CPU.
- Với công việc I/O (DB, HTTP, storage), có thể đặt cao hơn, nhưng dừng khi phụ thuộc bắt đầu timeout hoặc throttle.
- Với công việc hỗn hợp, đo và tinh chỉnh. Phạm vi khởi đầu thường là 2x đến 10x số core CPU, rồi hiệu chỉnh.
- Tôn trọng giới hạn chia sẻ. Nếu pool DB là 20 kết nối, 200 worker chỉ gây tranh giành trên 20 kết nối đó.
2) Chọn queue và đặt kích thước
Channel có buffer thường dùng vì đã có sẵn và dễ hiểu. Buffer là bộ giảm sốc cho các đợt spike.
Buffer nhỏ lộ overloaded sớm (sender block sớm hơn). Buffer lớn hơn làm phẳng spike nhưng có thể che giấu vấn đề và tăng bộ nhớ và latency. Chọn kích thước có chủ đích và quyết định chuyện gì xảy ra khi nó đầy.
3) Làm cho mọi task có thể bị huỷ
Truyền context.Context vào mỗi job và đảm bảo code job dùng nó (DB, HTTP). Đây là cách dừng gọn khi deploy, shutdown, và 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) Thêm metrics bạn thực sự dùng
Nếu chỉ theo dõi vài con số, hãy lấy những cái này:
- Độ sâu queue (queue depth)
- Thời gian worker bận (worker busy time)
- Thời lượng task (p50, p95, p99)
- Tỷ lệ lỗi (và số lần retry nếu bạn retry)
Đó là đủ để tinh chỉnh số worker và kích thước queue dựa trên bằng chứng, không phải đoán mò.
Sai lầm và bẫy thường gặp
Hầu hết team không bị ảnh hưởng vì chọn sai pattern. Họ gặp sự cố do các mặc định nhỏ tích tụ thành outage khi traffic spike hoặc phụ thuộc flaky.
Khi goroutine nhân lên
Bẫy cổ điển là khởi một goroutine cho mỗi job trong một đợt spike. Vài trăm ổn. Hàng trăm nghìn có thể làm đầy scheduler, heap, log, và socket mạng. Dù mỗi goroutine nhỏ, tổng chi phí cộng lại, và phục hồi mất thời gian vì công việc đã đang chạy.
Sai lầm khác là coi channel buffer khổng lồ là “backpressure.” Buffer lớn chỉ là queue ẩn. Nó có thể mua thời gian, nhưng cũng che giấu vấn đề cho đến khi bạn chạm tường bộ nhớ. Nếu cần queue, đặt kích thước có chủ đích và quyết định hành vi khi đầy (block, drop, retry sau, hoặc persist).
Nút thắt ẩn
Nhiều job nền không bị bound CPU. Chúng bị giới hạn bởi thứ gì đó hạ nguồn. Nếu bạn bỏ qua những giới hạn đó, producer nhanh làm consumer chậm bị quá tải.
Bẫy phổ biến:
- Không có huỷ hoặc timeout, nên worker có thể block mãi trên API hay truy vấn DB
- Số worker chọn mà không kiểm tra giới hạn thực như kết nối DB, I/O đĩa, hay hạn mức API bên thứ ba
- Retry khuếch đại tải (retry ngay lập tức cho 1.000 job thất bại)
- Một lock chia sẻ hoặc một transaction đơn làm mọi thứ tuần tự, nên “thêm worker” chỉ thêm overhead
- Thiếu hiển thị: không có metrics cho queue depth, tuổi job, số retry, và độ sử dụng worker
Ví dụ: một export ban đêm kích hoạt 20.000 task “gửi thông báo”. Mỗi task chạm DB và email provider, dễ vượt pool kết nối hoặc quota. Pool 50 worker với timeout per-task và queue nhỏ làm giới hạn rõ ràng. Một goroutine cho mỗi task cộng buffer khủng làm hệ thống trông ổn cho đến khi nó không còn.
Ví dụ: export và thông báo đột ngột
Hình dung nhóm support cần dữ liệu cho audit. Một người bấm "Export", vài đồng nghiệp cũng bấm, và đột ngột bạn có 5.000 job export trong một phút. Mỗi export đọc DB, format CSV, lưu file, và gửi thông báo (email hoặc Telegram) khi xong.
Với goroutine-per-task, hệ thống trông tốt một lúc. 5.000 job bắt đầu gần như cùng lúc, và có vẻ queue đang rút nhanh. Rồi chi phí xuất hiện: hàng ngàn truy vấn DB đồng thời tranh pool kết nối, bộ nhớ tăng khi job giữ buffer cùng lúc, và timeout trở nên phổ biến. Những job có thể xong nhanh bị kẹt sau retry và truy vấn chậm.
Với worker pool, khởi đầu chậm hơn nhưng tiến trình yên ắng hơn. Với 50 worker, chỉ 50 export làm việc nặng cùng lúc. Việc sử dụng DB nằm trong khoảng dự đoán, buffer được tái dùng thường xuyên hơn, và latency ổn định. Thời gian hoàn thành tổng thể dễ ước lượng hơn: xấp xỉ (jobs / workers) * thời lượng trung bình mỗi job, cộng một số overhead.
Điểm khác biệt không phải pool thần kỳ nhanh hơn. Mà là pool ngăn hệ thống tự gây hại trong đợt spike. Chạy có kiểm soát 50-lần-một thường hoàn thành nhanh hơn 5.000 job đánh nhau tài nguyên.
Nơi áp backpressure tùy thuộc bạn muốn bảo vệ gì:
- Ở lớp API, từ chối hoặc trì hoãn request export mới khi hệ thống bận.
- Ở queue, tiếp nhận request nhưng enqueue job và rút ở tốc độ an toàn.
- Trong worker pool, giới hạn concurrency cho phần tốn kém (đọc DB, tạo file, gửi thông báo).
- Theo resource, tách ra giới hạn riêng (ví dụ 40 worker cho export nhưng chỉ 10 cho notification).
- Với gọi ngoài, rate-limit email/SMS/Telegram để tránh bị block.
Checklist nhanh trước khi deploy
Trước khi chạy job nền production, rà soát giới hạn, hiển thị, và xử lý lỗi. Hầu hết incident không do “code chậm.” Chúng đến từ thiếu hàng rào khi tải spike hoặc phụ thuộc kém.
- Đặt max concurrency cố định cho từng phụ thuộc. Đừng dùng một số global rồi hy vọng hợp cho mọi thứ. Giới hạn ghi DB, gọi HTTP outbound, và công việc nặng CPU riêng.
- Làm queue có giới hạn và quan sát được. Đặt giới hạn thực cho job chờ và expose vài metric: queue depth, tuổi job lớn nhất, và tốc độ xử lý.
- Thêm retry với jitter và dead-letter path. Retry chọn lọc, trải retry ra, và sau N failure chuyển job vào dead-letter queue hoặc bảng “failed” với đủ thông tin để review và replay.
- Kiểm tra hành vi shutdown: drain, cancel, resume an toàn. Quyết định deploy hoặc crash thì sao. Làm job idempotent để reprocess an toàn, và lưu tiến độ cho workflow dài.
- Bảo vệ hệ thống bằng timeout và circuit breaker. Mọi gọi ngoài cần timeout. Nếu phụ thuộc down, fail fast (hoặc tạm ngưng intake) thay vì chất đống công việc.
Bước thực tế tiếp theo
Chọn pattern phù hợp với hình dạng hệ thống trong ngày bình thường, không phải ngày hoàn hảo. Nếu công việc đến theo đợt (upload, export, gửi email), worker pool cố định với queue có giới hạn thường là mặc định an toàn hơn. Nếu công việc đều và mỗi tác vụ nhỏ, goroutine-per-task có thể ổn, miễn là bạn vẫn đặt giới hạn ở đâu đó.
Lựa chọn thắng thường là cái làm lỗi trở nên nhàm chán. Pool làm giới hạn hiển nhiên. Goroutine-per-task dễ quên giới hạn cho tới spike đầu tiên.
Bắt đầu đơn giản, rồi thêm giới hạn và hiển thị
Bắt đầu với thứ đơn giản, nhưng thêm hai cơ chế sớm: giới hạn concurrency và cách nhìn thấy queue và lỗi.
Kế hoạch rollout thực tế:
- Định hình workload: bursty, steady, hay mixed (và peak là gì).
- Đặt giới hạn in-flight (pool size, semaphore, hoặc channel có giới hạn).
- Quyết định khi chạm giới hạn: block, drop, hay trả lỗi rõ ràng.
- Thêm metric cơ bản: queue depth, thời gian trong queue, thời gian xử lý, retry, và dead letters.
- Load test với burst gấp 5 lần peak kỳ vọng và quan sát bộ nhớ và latency.
Khi một pool không đủ
Nếu workflow chạy từ phút tới ngày, pool đơn giản khó đủ vì công việc không chỉ “làm một lần.” Bạn cần trạng thái, retry, và khả năng resume. Thường có nghĩa là persist tiến độ, làm các bước idempotent, và áp backoff. Cũng có thể tách job lớn thành bước nhỏ để resume an toàn sau crash.
Nếu bạn muốn đưa backend có workflow nhanh hơn, AppMaster (appmaster.io) có thể là lựa chọn thực tế: bạn mô hình dữ liệu và logic bằng giao diện trực quan, và nó sinh mã Go thực sự cho backend để bạn có thể giữ kỷ luật về giới hạn concurrency, queueing, và backpressure mà không phải nối tay mọi thứ.
Câu hỏi thường gặp
Ưu tiên dùng worker pool khi các job có thể đến theo đợt lớn hoặc chạm vào các giới hạn chia sẻ như kết nối DB, CPU, hoặc hạn mức API bên thứ ba. Dùng goroutine-per-task khi khối lượng nhỏ, tác vụ ngắn, và bạn vẫn có một giới hạn rõ ràng ở đâu đó (ví dụ semaphore hoặc rate limiter).
Khởi một goroutine cho mỗi tác vụ dễ viết và có thể cho throughput tốt khi tải thấp, nhưng nó có thể tạo backlog không giới hạn khi có spike. Worker pool thêm giới hạn đồng thời cố định và một chỗ rõ ràng để đặt timeout, retry và metrics, giúp hành vi production dự đoán được hơn.
Thường không giảm nhiều. Trong hầu hết hệ thống, throughput bị giới hạn bởi nút thắt chia sẻ như cơ sở dữ liệu, API bên ngoài, I/O đĩa, hoặc bước nặng CPU. Thêm goroutine không vượt được giới hạn đó; thường chỉ làm tăng thời gian chờ và sự cạnh tranh.
Goroutine-per-task thường có latency tốt khi tải thấp, nhưng có thể tệ đi rất nhiều khi tải cao do mọi thứ cùng cạnh tranh. Pool có thể thêm độ trễ do xếp hàng, nhưng thường giữ p99 ổn định hơn bằng cách tránh một đám đông đồng loạt làm việc trên cùng phụ thuộc.
Không phải goroutine mà là backlog gây spike bộ nhớ. Nếu các tác vụ dồn lại và mỗi tác vụ giữ payload hoặc object lớn, bộ nhớ tăng nhanh. Worker pool với hàng đợi có ràng buộc biến điều đó thành một ngưỡng bộ nhớ xác định và hành vi quá tải có thể dự đoán được.
Backpressure nghĩa là chậm lại hoặc ngừng nhận việc mới khi hệ thống đã bận thay vì để công việc tích tụ vô hình. Một hàng đợi có giới hạn là dạng đơn giản: khi đầy, producer block hoặc bạn trả lỗi, ngăn chặn tình trạng bộ nhớ và pool kết nối bị cạn kiệt.
Bắt đầu từ giới hạn thực tế. Với tác vụ nặng CPU, bắt đầu gần số core CPU. Với tác vụ I/O, có thể đặt cao hơn, nhưng dừng lại khi DB, mạng, hoặc API bên ngoài bắt đầu timeout hoặc throttle, và đảm bảo tôn trọng kích thước pool kết nối.
Chọn kích thước hấp thụ các đợt bình thường nhưng không che giấu vấn đề quá lâu. Buffer nhỏ lộ overloaded sớm; buffer lớn làm tăng bộ nhớ và khiến người dùng đợi lâu hơn trước khi lỗi xuất hiện. Quyết định trước hành vi khi queue đầy: block, reject, drop, hoặc persist ở nơi khác.
Dùng context.Context cho mỗi job và đảm bảo các gọi DB/HTTP tôn trọng nó. Đặt timeout cho gọi ngoài, và làm rõ hành vi shutdown để worker có thể dừng gọn mà không để goroutine treo hay công việc dở dang.
Theo dõi queue depth, thời gian chờ trong queue, thời lượng xử lý (p50/p95/p99) và số lỗi/retry. Những metrics này cho biết bạn cần thêm worker, giảm queue, thắt chặt timeout, hoặc giới hạn tốc độ với một dependency.


