Tác vụ chạy lâu
Cách xử lý việc mất vài giây tới vài giờ mà không giữ request: nhận job, trả 202 kèm job id, worker xử lý, báo kết quả qua polling, webhook hoặc SSE. Kèm hàng đợi, retry có backoff và jitter, DLQ, idempotency, heartbeat, lập lịch, workflow engine và cách tính số worker.
Bài toán: việc chậm trong một hệ thống chờ phản hồi nhanh
Người dùng bấm "Xuất báo cáo năm". Server phải đọc vài triệu dòng, tổng hợp, sinh file PDF 40 trang. Việc này mất 3 phút. Các ví dụ khác cùng dạng: transcode một video, chạy code người dùng nộp trong LeetCode, gửi email tới 2 triệu người, crawl một website, sinh câu trả lời dài từ mô hình AI, đối soát giao dịch cuối ngày.
Chúng có chung ba đặc điểm:
- Thời gian xử lý dài hơn nhiều so với thời gian một request HTTP nên kéo dài (vài trăm ms tới vài giây, ước tính của khoá học).
- Có thể thất bại giữa chừng vì lý do tạm thời: dịch vụ bên ngoài timeout, máy bị thu hồi, deploy.
- Người dùng cần biết tiến độ và kết quả, nhưng không cần ngồi chờ trước màn hình.
Pattern trong bài này tách hai việc: nhận yêu cầu (nhanh, đồng bộ) và thực thi yêu cầu (chậm, bất đồng bộ, có thể làm lại). Grokking ch.17 nêu đúng lợi ích này của messaging queue: producer gửi message rồi làm tiếp thay vì chờ tác vụ chậm, producer và consumer hỏng độc lập và khởi động lại sau.1
Cách ngây thơ: làm luôn trong request
app.post("/reports", async (req, res) => {
const rows = await loadYear(req.user.id, req.body.year); // 60 s
const pdf = await renderPdf(rows); // 90 s
const url = await upload(pdf);
res.json({ url });
});
Cách này gãy vì:
- Timeout ở mọi tầng. Trình duyệt, CDN, load balancer, API gateway đều có giới hạn thời gian. Nhiều gateway đặt timeout ngắn (tuỳ cấu hình). Request bị cắt nhưng server vẫn chạy tiếp, tốn tài nguyên cho kết quả không ai nhận.2
- Không chịu được lỗi. Process chết ở phút thứ 2 là mất hết. Client thử lại thì làm lại từ đầu, có khi chạy hai lần song song.
- Chiếm tài nguyên của API. Việc nặng CPU chặn event loop Node.js, làm chậm mọi request khác trên cùng instance (Node.js Design Patterns ch.9 mô tả CPU-bound task chặn event loop và độ trễ khi chặn vài trăm ms).3 Bạn phải scale API theo việc nặng nhất, trong khi phần lớn request rất nhẹ.
- Không kiểm soát được tải. 500 người cùng bấm xuất báo cáo là 500 việc nặng chạy cùng lúc, database bị dồn tải. Grokking ghi hàng đợi giúp hấp thụ đỉnh tải và tránh quá tải service.1
Giải pháp 1: async job pattern (202 + job id + worker)
Đây là nền tảng của mọi giải pháp khác. Việc dùng mã 202 Accepted, job id và các cách báo kết quả bên dưới là thực hành API của khoá học.2 API nhận yêu cầu, lưu một bản ghi job, đẩy job vào hàng đợi, trả về 202 Accepted kèm jobId ngay lập tức. Worker riêng lấy job ra xử lý và cập nhật trạng thái.
app.post("/reports", async (req, res) => {
const key = req.header("Idempotency-Key");
const job = await jobs.createOnce(key, { type: "report", userId: req.user.id, params: req.body });
if (job.created) await queue.add("report", { jobId: job.id }, { jobId: job.id });
res.status(202).location(`/jobs/${job.id}`).json({ jobId: job.id, status: job.status });
});
Bảng jobs lưu id, type, status (queued, running, succeeded, failed, cancelled), progress, attempts, result, error, created_at, updated_at. Bảng này là nguồn sự thật về trạng thái. Hàng đợi chỉ là cơ chế phân phối việc, giống Grokking ch.23: task được lưu vào database quan hệ trước, rồi mới đẩy các task ưu tiên cao nhất vào distributed queue.4
Báo kết quả cho client
| Cách | Hoạt động | Hợp với | Đánh đổi |
|---|---|---|---|
| Polling | Client gọi GET /jobs/:id mỗi 2 tới 5 giây | Mặc định, đơn giản nhất | Tốn request vô ích, độ trễ bằng chu kỳ poll |
| SSE / WebSocket | Server đẩy sự kiện tiến độ qua kết nối mở | UI cần progress bar mượt, AI streaming | Phải giữ kết nối, cần pub/sub để worker báo về đúng gateway |
| Webhook | Server gọi URL của khách khi job xong | Khách là hệ thống khác (API công khai) | Phải ký payload, retry khi khách lỗi, chống SSRF |
| Email / push | Gửi thông báo khi xong | Job hàng chục phút trở lên | Không phù hợp cho tiến độ chi tiết |
Thực tế thường kết hợp: SSE khi người dùng đang mở trang, polling làm dự phòng, email nếu job dài (gợi ý của khoá học).2 Xem thêm Cập nhật realtime.
Giải pháp 2: hàng đợi tin cậy (retry, backoff, DLQ, idempotency, heartbeat)
Đưa job vào hàng đợi mới giải quyết một nửa. Nửa còn lại là đảm bảo job chạy xong đúng một lần về mặt hiệu ứng, kể cả khi worker chết. Microservices Patterns §3.3.6 nói đảm bảo exactly-once thường quá tốn kém, nên hầu hết broker chỉ hứa giao ít nhất một lần.5
Chọn hàng đợi
| Hàng đợi | Mô hình | Hợp với | Lưu ý |
|---|---|---|---|
| BullMQ (Redis) | Job queue, mỗi job có trạng thái, retry, delay, priority | Hệ thống Node.js vừa và nhỏ, job đa dạng | Phụ thuộc RAM Redis, cần cấu hình persistence |
| SQS | Message queue có visibility timeout, DLQ sẵn có | Chạy trên AWS, không muốn vận hành | Có giới hạn về visibility timeout và thời gian giữ message (xem tài liệu nhà cung cấp) |
| Kafka | Log có partition, consumer commit offset | Luồng sự kiện lượng lớn, cần replay | Không ack từng message. Một job chậm chặn cả partition của consumer đó, và xử lý quá lâu có thể kích hoạt rebalance |
| RabbitMQ | Broker với ack từng message, routing linh hoạt | Routing phức tạp, ưu tiên | Tự vận hành |
Với job dài và không đồng đều về thời gian, job queue (BullMQ, SQS) thường hợp hơn Kafka. Kafka tốt khi job ngắn, đồng đều, và lượng cực lớn. Về phía sách, Microservices Patterns §3.3.5 mô tả kênh chia shard của Kafka: mỗi shard được gán cho đúng một receiver trong consumer group, nên thứ tự được giữ nhưng một receiver chậm làm chậm shard của nó.6 Bảng so sánh sản phẩm còn lại là nhận định của khoá học.7
Visibility timeout và heartbeat
Grokking (ch.17) mô tả cơ chế cốt lõi: khi consumer lấy một message, message không bị xóa ngay mà bị ẩn trong một khoảng visibility_timeout. Chỉ consumer mới xóa message (bằng một lệnh gọi API) sau khi xử lý xong. Nếu consumer chết và không xóa, hết thời gian ẩn thì message hiện lại cho worker khác lấy. Cơ chế này cho bạn giao hàng ít nhất một lần (at-least-once).8
Vấn đề với job dài: nếu đặt timeout 30 giây mà job chạy 10 phút, job sẽ hiện lại và một worker thứ hai chạy song song. Nếu đặt timeout 2 giờ, worker chết thì job nằm im 2 giờ mới được chạy lại. Grokking ch.17 đặt đúng câu hỏi này (điều gì xảy ra khi visibility timeout hết mà consumer vẫn đang xử lý) và gợi ý đặt ngưỡng an toàn.8 Cách giải của khoá học là heartbeat: đặt timeout ngắn (ví dụ 60 giây) và để worker gia hạn định kỳ (SQS ChangeMessageVisibility, BullMQ tự gia hạn lock khi worker còn sống). Worker còn sống thì job còn được giữ. Worker chết thì job được trả lại nhanh. Trong Grokking ch.23, cluster manager nhận heartbeat từ từng node để biết node còn sống.97
Retry với exponential backoff và jitter
Lỗi tạm thời nên được thử lại, nhưng không thử lại ngay. Grokking ch.23 nói task không bị xóa khỏi hàng đợi ở lần chạy đầu và được retry tới số lần tối đa cho phép.10 Phần backoff lũy thừa và jitter là kỹ thuật của khoá học: nếu dịch vụ phía sau đang quá tải, 1.000 job cùng retry ngay sẽ làm nó sập hẳn, nên thêm jitter ngẫu nhiên để các job không retry cùng một thời điểm.11
// "Full jitter": chờ ngẫu nhiên trong [0, min(cap, base * 2^attempt)]
function backoffMs(attempt: number, base = 1000, cap = 5 * 60_000) {
return Math.random() * Math.min(cap, base * 2 ** attempt);
}
Phân biệt lỗi: lỗi tạm thời (timeout, 503, mất kết nối) thì retry. Lỗi vĩnh viễn (dữ liệu sai, 400, file hỏng) thì đánh dấu thất bại ngay, retry chỉ tốn tài nguyên.
Dead letter queue
Sau N lần thất bại, job được chuyển sang dead letter queue. Building Microservices kể một sự cố thật: một request làm worker chết, lock hết hạn nên request quay lại hàng đợi và làm chết worker khác, vì không đặt giới hạn retry. Nhóm sửa bằng cách đặt retry tối đa và dựng một "message hospital" (dead letter queue) kèm giao diện xem và retry các message lỗi.12 Việc cảnh báo khi DLQ có message là thực hành của khoá học.11
Idempotency
At-least-once nghĩa là job có thể chạy hai lần. Microservices Patterns (ch.3, §3.3.6) đưa ra hai hướng xử lý message trùng: viết handler idempotent tự nhiên, hoặc ghi lại id của message đã xử lý (ví dụ trong bảng PROCESSED_MESSAGE) và bỏ qua nếu gặp lại.5 Grokking ch.23 minh hoạ cùng vấn đề bằng ví dụ chuyển 10 đô bị retry và dùng idempotence key để chỉ thực hiện một lần.13 Áp dụng cho job:
- Ở API: nhận
Idempotency-Keytừ client, cùng key trả về cùng job, không tạo job mới. - Ở worker: mỗi bước có hiệu ứng ra ngoài (trừ tiền, gửi email) mang theo id duy nhất dẫn xuất từ
jobId, để dịch vụ phía sau loại trùng. - Ghi kết quả bằng thao tác ghi đè theo key cố định (
reports/{jobId}.pdf), chạy hai lần cho cùng một kết quả.
Sai lầm hay gặp: Gửi email trong job rồi mới cập nhật trạng thái done. Worker chết giữa hai bước, job chạy lại, khách nhận hai email. Ghi lại "đã gửi email cho jobId X" trước khi xác nhận job, và kiểm tra bản ghi đó trước khi gửi.
Giải pháp 3: lập lịch tác vụ
Nhiều tác vụ không chạy ngay mà chạy vào một thời điểm: gửi nhắc lịch lúc 8 giờ sáng, hết hạn giữ vé sau 10 phút, đối soát mỗi đêm, crawl lại trang sau 24 giờ.
Với việc trễ ngắn và ít, delayed job của BullMQ (delay: ms) hoặc delay của SQS là đủ (tính năng sản phẩm, xem tài liệu nhà cung cấp).7 Với hệ thống lập lịch thật sự, Grokking (ch.23 Distributed Task Scheduler) đưa ra kiến trúc gồm các phần chính:
Vài ý từ chương này rất đáng mang vào phỏng vấn:
- Lưu task vào database trước, đẩy vào hàng đợi sau. Sách lưu task vào RDB, gom batch, rồi đẩy K task ưu tiên cao nhất vào distributed queue;4 câu truy vấn quét
WHERE run_at <= now() AND status = 'scheduled'là cách cài đặt minh hoạ của khoá học. Database là nguồn sự thật, hàng đợi chỉ chứa việc sắp chạy. - Phân loại theo mức chịu trễ. Sách chia task thành ba loại (không thể trễ, có thể trễ, định kỳ), mỗi loại một hàng đợi; task chịu trễ được đẩy lên hàng khẩn khi sắp chạm giới hạn để không bị bỏ đói, và task có delay tolerance ngắn nhất chạy trước.14
- Checkpoint cho task dài. Task chạy hàng giờ nên lưu trạng thái định kỳ để khi máy chết có thể chạy tiếp từ checkpoint thay vì từ đầu; nếu không, task phải idempotent để chạy lại trên máy khác.15
- Giới hạn thời gian và tài nguyên. Sách dùng tham số execution cap: task vượt thời gian cho phép bị dừng và client được báo, để task lỗi (ví dụ vòng lặp vô hạn) không chiếm tài nguyên mãi.1510
Để nhiều instance scheduler cùng quét mà không đẩy trùng, dùng SELECT ... FOR UPDATE SKIP LOCKED trong Postgres, hoặc chia task theo partition và mỗi scheduler sở hữu một số partition (kỹ thuật của khoá học).7 Bài Job scheduler đi sâu phần này.
Giải pháp 4: workflow engine cho việc nhiều giờ, nhiều bước
Khi job gồm nhiều bước, mỗi bước gọi dịch vụ khác, có thể kéo dài hàng giờ hoặc hàng ngày, có nhánh, có chờ người duyệt, thì hàng đợi + bảng jobs bắt đầu thành mớ code trạng thái tự viết. Ví dụ: onboarding khách hàng (kiểm tra KYC, chờ duyệt, tạo tài khoản, gửi thẻ), pipeline xử lý video (tách đoạn, transcode song song 200 đoạn, ghép, sinh phụ đề), quy trình hoàn tiền.
Workflow engine như Temporal, AWS Step Functions hoặc Cadence cho phép viết quy trình như một hàm bình thường. Engine ghi lại lịch sử từng bước và tự khôi phục sau lỗi.16 Ý tưởng điều phối nhiều bước có bù trừ có trong Microservices Patterns ch.4 (saga), xem Quy trình nhiều bước.17
export async function transcodeVideo(videoId: string) {
const parts = await activities.split(videoId); // retry tự động
await Promise.all(parts.map((p) => activities.transcode(p))); // fan-out song song
await activities.merge(videoId);
await workflow.sleep("1 day"); // ngủ 1 ngày không tốn worker
await activities.cleanupTemp(videoId);
}
| Tiêu chí | Queue + bảng jobs | Workflow engine |
|---|---|---|
| Độ phức tạp ban đầu | Thấp | Cao hơn, thêm một hệ thống để vận hành |
| Job một bước, vài phút | Rất hợp | Thừa |
| Nhiều bước, nhánh, chờ sự kiện ngoài | Code trạng thái tự viết, dễ lỗi | Viết như code tuần tự |
| Khôi phục sau lỗi | Tự cài checkpoint | Có sẵn, replay từ lịch sử |
| Quan sát tiến độ | Tự xây | Có UI xem từng bước |
Xem thêm Quy trình nhiều bước cho saga và bù trừ.
Scale theo quy mô và tính số worker
Công thức nền là định luật Little: số job chạy đồng thời = tốc độ job đến × thời gian xử lý trung bình. Công thức này tương tự cách Grokking ch.5 ước lượng số request mỗi giây từ số luồng và thời gian xử lý (RPS = số CPU × 1/thời gian tác vụ).18 Từ số job đồng thời suy ra số worker, rồi cộng dư địa cho đỉnh và retry.
Ví dụ (giả định của khoá học19): hệ thống xuất báo cáo, mỗi user hoạt động tạo 0,2 job mỗi ngày, mỗi job mất trung bình 60 giây CPU, mỗi worker 2 vCPU chạy 2 job song song. Đỉnh gấp 5 lần trung bình. Các con số là ước tính.
| Quy mô | Job/ngày | Job/s đỉnh | Job đồng thời đỉnh | Worker 2 vCPU | Kiến trúc gợi ý |
|---|---|---|---|---|---|
| ~10k user | 2.000 | ~0,12 | ~7 | ~4 | BullMQ trên Redis sẵn có, bảng jobs trong Postgres, polling |
| ~1M user | 200.000 | ~12 | ~700 | ~350 | Hàng đợi riêng theo loại job, autoscale theo độ dài hàng đợi, DLQ có cảnh báo, SSE cho tiến độ |
| 50M+ user | 10M | ~580 | ~35.000 | ~17.500 | Nhiều cụm worker theo region và loại job, priority queue, giới hạn quota theo tenant, workflow engine cho job nhiều bước, spot instance cho job chịu trễ |
Cách tính dòng 1M: 200.000 / 86.400 ≈ 2,3 job/s trung bình, đỉnh ×5 ≈ 12 job/s. 12 × 60 giây = 700 job đồng thời. Mỗi worker chạy 2 job, cần 350 worker lúc đỉnh. Ngoài giờ đỉnh chỉ cần khoảng 70, nên autoscale tiết kiệm rất nhiều.
Những con số này chỉ ra vài quyết định quan trọng:
- Autoscale theo độ dài hàng đợi hoặc tuổi job cũ nhất, không theo CPU của API (Grokking ch.23 cũng thêm hoặc bớt tài nguyên theo số task, kèm monitoring service cảnh báo).20 Metric "job cũ nhất đã chờ bao lâu" phản ánh trực tiếp trải nghiệm người dùng.
- Không phải mọi job đều cần chạy ngay ở đỉnh. Nếu cho phép báo cáo chờ tới 10 phút, hàng đợi có thể hấp thụ đỉnh và số worker cần thiết gần với mức trung bình hơn.
- Tách hàng đợi theo loại job. Job 2 giây và job 2 giờ trong cùng hàng đợi sẽ khiến job nhanh phải chờ sau job chậm.
- Giới hạn quota theo user hoặc tenant để một khách gửi 100.000 job không chiếm hết worker của mọi người (Grokking ch.23 nhấn mạnh cách ly hiệu năng giữa các tenant và giới hạn task hư).21
- Bảo vệ tài nguyên phía sau. 700 job đồng thời cùng truy vấn database có thể làm database quá tải, nên giới hạn concurrency của từng loại job theo sức chịu của tài nguyên chung.
Trong phỏng vấn: Khi thiết kế có bước chậm, hãy nói rõ bốn thứ: API trả 202 kèm job id, job nằm ở đâu (hàng đợi nào, bảng nào), điều gì xảy ra khi worker chết giữa chừng (visibility timeout, heartbeat, retry, idempotency), và client biết kết quả bằng cách nào.
Bài thiết kế nào dùng pattern này
Các bài dưới đây áp dụng hàng đợi, scheduler và retry.22
| Bài | Tác vụ chạy lâu |
|---|---|
| Job scheduler | Lập lịch, quét task tới hạn, executor, heartbeat, retry |
| LeetCode | Chạy code trong sandbox, trả kết quả qua polling hoặc WebSocket |
| YouTube | Transcode video song song theo đoạn |
| Web crawler | Hàng triệu tác vụ fetch, retry có backoff, politeness theo domain |
| Notification system | Gửi hàng loạt, retry, DLQ, idempotency khi gửi |
| ChatGPT | Sinh câu trả lời dài, stream qua SSE |
| Payment system | Đối soát, retry gọi cổng thanh toán, idempotency key |
| Price tracking | Job định kỳ kiểm tra giá, lập lịch theo mức ưu tiên |
Bài liên quan: File lớn, Kafka, Temporal, Scale ghi.
Nguồn trích dẫn
-
Grokking Modern System Design · ch.17 Distributed Messaging Queue · bài mở đầu · phần lợi ích của messaging queue (better reliability, granular scalability, decoupling, rate limiting). ↩ ↩2
-
Chưa có nguồn trong thư viện: mã 202, job id, polling/SSE/webhook và timeout gateway là thực hành API của khoá học; thư viện không có đoạn tương ứng. ↩ ↩2 ↩3
-
Node.js Design Patterns 2nd Edition (file .zip, tải về giải nén) · ch.9 Advanced Asynchronous Recipes · mục "Running CPU-bound tasks" (tr. 371). ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · bài "Design of a Distributed Task Scheduler" · mục "Components" (Batching and prioritization) và "Point to Ponder: why store tasks in a database". ↩ ↩2
-
Microservices Patterns (file .zip, tải về giải nén) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97). ↩ ↩2
-
Microservices Patterns (file .zip, tải về giải nén) · ch.3 · §3.3.5 Competing receivers and message ordering (tr. 94–95). ↩
-
Chưa có nguồn trong thư viện: so sánh sản phẩm (BullMQ, SQS, RabbitMQ), heartbeat/gia hạn lock, delayed job và SKIP LOCKED là kiến thức thực hành của khoá học, không có sách tương ứng. ↩ ↩2 ↩3 ↩4
-
Grokking Modern System Design · ch.17 Distributed Messaging Queue · bài "Design of a Distributed Messaging Queue" · phần consumer lấy và xóa message (visibility_timeout, at-least-once). ↩ ↩2
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · bài "Design of a Distributed Task Scheduler" · mục "Components" (cluster manager nhận heartbeat từ node). ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · bài "Evaluation of a Distributed Task Scheduler's Design" · mục "Fault tolerance" và "Bounded waiting time". ↩ ↩2
-
Chưa có nguồn trong thư viện: exponential backoff, jitter và cảnh báo DLQ không được mô tả trong sách của thư viện. ↩ ↩2
-
Building Microservices (file .zip, tải về giải nén) · ch.4 Integration · mục "Implementing Asynchronous Event-Based Collaboration" (tr. 57). ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · mục "Task idempotency". ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · mục "Queueing" và "Prioritization". ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · mục "Execution cap" (và Point to Ponder về checkpoint). ↩ ↩2
-
Chưa có nguồn trong thư viện: thư viện không có sách về Temporal/Step Functions/Cadence; phần này chỉ nêu khái niệm cơ bản. ↩
-
Microservices Patterns (file .zip, tải về giải nén) · ch.4 Managing transactions with sagas · §4.1.3 Using the Saga pattern to maintain data consistency và §4.2 Coordinating sagas. ↩
-
Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations · mục "Requests estimation" (công thức RPS theo số CPU và thời gian tác vụ). ↩
-
Quy tắc ước lượng của khoá học · /hoc/chon-server (đỉnh gấp 5 lần trung bình, 2 vCPU mỗi worker). ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · mục "Conclusion" và "Resource capacity optimization". ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler · mục "Schedule and execute untrusted tasks". ↩
-
Grokking Modern System Design · ch.23 Distributed Task Scheduler; ch.17 Distributed Messaging Queue. ↩