Lập lịch tác vụ phân tán
Hiểu đề
Yêu cầu chức năng
- Client có thể tạo job chạy một lần (run_at) hoặc lặp lại (biểu thức cron + múi giờ) kèm payload và đích gọi (HTTP endpoint hoặc topic)
- Hệ thống gọi đích của job khi đến giờ, truyền kèm run_id làm idempotency key
- Job lỗi được chạy lại theo chính sách backoff, quá số lần cho phép thì vào DLQ và báo chủ job
- Client có thể xem trạng thái, lịch sử các lần chạy, tạm dừng, sửa hoặc xóa job
Yêu cầu phi chức năng
- Không mất job đã nhận: ghi bền xuống DB trước khi trả 201
- Độ trễ khởi chạy p99 < 5 s ở tải thường; khi nhiều job dồn vào cùng một giờ (0h00, 9h00), job vẫn chạy trong delay tolerance khai báo (mặc định ≤ 10 phút)
- Giao ít nhất một lần; tỉ lệ một lần chạy bị gọi trùng < 0,01% và luôn có run_id để đích tự loại trùng
- Scale tới 1 tỷ lần chạy/ngày; API quản lý job sẵn sàng 99,9%, luồng thực thi 99,95%
Ngoài phạm vi: Workflow nhiều bước có phụ thuộc (DAG kiểu Airflow), chỉ nhắc ở mức hướng mở rộng; Cấp phát CPU/RAM để chạy code của người dùng; đích tự chạy logic, scheduler chỉ gọi; Chạy code không tin cậy trong sandbox; Giao diện kéo thả để soạn lịch.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| POST | /v1/jobs | Tạo job: {schedule: {type: 'once', runAt} | {type: 'cron', expr, tz}, target, payload, retryPolicy}. Header Idempotency-Key để client gửi lại không tạo trùng. |
| GET | /v1/jobs/{id} | Định nghĩa job kèm next_run_at và trạng thái lần chạy gần nhất. |
| PATCH | /v1/jobs/{id} | Tạm dừng / tiếp tục / đổi lịch. Đổi lịch thì hủy run SCHEDULED cũ và tính run kế tiếp. |
| DELETE | /v1/jobs/{id} | Xóa mềm job, các run chưa chạy chuyển sang CANCELLED. |
| GET | /v1/jobs/{id}/runs?status=&cursor= | Lịch sử các lần chạy, phân trang theo cursor. |
| POST | /v1/runs/{runId}/heartbeat | Dành cho tác vụ dài: đích báo vẫn đang chạy để gia hạn lease, kèm attempt làm fencing token. |
Thiết kế tổng thể
Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.
1) Client có thể tạo job một lần hoặc theo cron
Job được lưu bền trước khi trả lời. Với job cron, chỉ sinh sẵn một run kế tiếp chứ không sinh trước cả năm, nên đổi lịch hay xóa job rất rẻ.
- 1Client gọi POST /v1/jobs với Idempotency-Key
- 2API kiểm tra cron hợp lệ, tính next_run_at theo múi giờ của job
- 3Trong 1 transaction: INSERT Job và INSERT JobRun đầu tiên (status SCHEDULED, scheduled_at = next_run_at)
- 4Trả 201 kèm job_id và next_run_at
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Task submitter" và "Components" (Database)
2) Hệ thống gọi đích của job khi đến giờ
Poller chỉ đọc những run đến hạn qua index theo thời gian rồi đẩy vào queue; worker lấy từ queue, nhận lease rồi mới gọi đích. Tách hai vai trò này để poller nhẹ và worker scale độc lập.
- 1Poller mỗi giây: SELECT run đến hạn (scheduled_at <= now() + 1 s) ORDER BY scheduled_at LIMIT 500 FOR UPDATE SKIP LOCKED, đổi sang QUEUED
- 2Đẩy run_id vào queue; với job cron thì tính và INSERT run kế tiếp ngay lúc này
- 3Worker nhận message, UPDATE run sang RUNNING, attempt + 1, lease_until = now() + 30 s nếu run còn QUEUED hoặc lease cũ đã hết
- 4Worker gọi đích với header Idempotency-Key = run_id, chờ tối đa timeout của target
- 52xx thì đánh dấu SUCCEEDED (kèm điều kiện attempt khớp) rồi ack message
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Queueing" ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Message deletion" (visibility_timeout, dead-letter queue)
3) Job lỗi được chạy lại, hết lượt thì vào DLQ
Lần thử lại được hẹn giờ như một run bình thường với scheduled_at mới, nên worker không phải ngủ chờ. Lỗi được phân loại để không chạy lại những lỗi chắc chắn sẽ lặp lại.
- 1Đích trả 5xx hoặc timeout: status RETRY, scheduled_at = now() + backoff (2^attempt × 10 s, cộng jitter)
- 2Đích trả 4xx (trừ 429): lỗi cố định, chuyển thẳng sang DEAD
- 3attempt vượt max_attempts: status DEAD, ghi DeadLetter, gửi cảnh báo cho chủ job
- 4Chủ job sửa lỗi rồi gọi redrive: tạo run mới, giữ nguyên run_id gốc trong payload để đích vẫn loại trùng được
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Message deletion" (visibility_timeout, dead-letter queue) ; Grokking Modern System Design · ch.23 ↗ · mục "Components" (Queue manager: retry theo số lần cho phép)
4) Client có thể xem trạng thái và lịch sử chạy
Lịch sử chạy lớn rất nhanh nên tách khỏi phần dữ liệu nóng: run còn sống nằm trong bảng chính, run đã xong được partition theo ngày và xóa hoặc chuyển kho lạnh sau 30 ngày.
- 1GET /v1/jobs/{id}/runs đọc từ read replica, index (job_id, scheduled_at DESC)
- 2Partition theo ngày, DROP partition cũ thay vì DELETE từng hàng
- 3Metric: số run trễ, tỉ lệ lỗi theo target, độ dài queue được đẩy lên dashboard và cảnh báo
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Components" (Database: attempts, delay tolerance) ; Chưa có nguồn trong thư viện: partition theo ngày là kỹ thuật DB chung
Hạ tầng theo quy mô
Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.
Scheduler của một sản phẩm nhỏ. Một service Node chạy cả API, poller và worker, Postgres managed với SKIP LOCKED là đủ; không cần queue riêng.
Ước lượng
- Trung bình: 50.000 lần chạy / 86.400 ≈ 0,58 lần/s
- Đỉnh 09:00: 5.000 job, muốn xong trong 60 s → 5.000 / 60 ≈ 83 lần/s
- Worker: 1 instance 2 vCPU gọi đích 200 ms/lần, 200 lời gọi song song → 200 / 0,2 = 1.000 lần/s, giữ 60% → 600; ceil(83 / 600) = 1 → chạy 2 để dự phòng
- Ghi DB: mỗi lần chạy 3 ghi (nhận lease, hoàn tất, tạo run kế) → 83 × 3 ≈ 250 ghi/s lúc đỉnh
- Lịch sử: 50.000 × 1 KB = 50 MB/ngày × 30 ngày = 1,5 GB
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Standard numbers to remember" và "Requests estimation" ; Quy tắc ước lượng của khoá học · /hoc/chon-server
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| Scheduler service | Node.js (NestJS): API + poller + worker trong cùng tiến trình | 2 | 2 vCPU · 4 GB RAM, luôn chạy (không scale về 0) | 83 lần/s chỉ cần 1 instance; 2 instance cùng poll an toàn nhờ SKIP LOCKED. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | 250 ghi/s lúc đỉnh và 1,5 GB lịch sử là nhỏ; partial index trên run SCHEDULED. |
| Đích gọi | HTTP endpoint của các service khác | - | Ký HMAC, timeout 30 s | Scheduler chỉ gọi, logic nằm ở đích. |
| Cảnh báo | Slack webhook / email | 1 | Dịch vụ bên ngoài | Báo chủ job khi run vào DEAD. |
| Giám sát | Cloud Monitoring + log | 1 | Managed | Theo dõi số run trễ và tỉ lệ lỗi là đủ ở mức này. |
- Điểm xuất phát: một service, một Postgres
- Mỗi job chỉ có một run kế tiếp, tìm bằng index scheduled_at
- Lease nằm ngay trên hàng run, chưa cần queue
Poller và worker chung tiến trình với API nên một đợt chạy lớn làm API chậm theo. Khi lên hàng triệu lần chạy/ngày cần tách worker và đặt queue ở giữa.
Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.
So sánh nhanh ba mức
| Thành phần | Mức 1 | Mức 2 | Mức 3 |
|---|---|---|---|
| Người dùng | 1 sản phẩm ~10.000 DAU, 50.000 lần chạy/ngày | Nền tảng chung cho cả công ty (các sản phẩm ~1 triệu DAU), 10 triệu lần chạy/ngày | Nền tảng scheduler cho nhiều sản phẩm lớn (tổng 50 triệu+ DAU), 500 triệu job, 1 tỷ lần chạy/ngày |
| Chi phí | ≈ 60–150 USD/tháng (ước lượng) | ≈ 1.500–4.000 USD/tháng (ước lượng) | ≈ 40.000–100.000 USD/tháng (ước lượng) |
| Scheduler service | Node.js (NestJS): API + poller + worker trong cùng tiến trình 2 × 2 vCPU · 4 GB RAM, luôn chạy (không scale về 0) | Chưa cần | Chưa cần |
| Database | PostgreSQL managed 1 × 2 vCPU · 8 GB · 50 GB SSD | PostgreSQL managed (HA) 1 primary + 1 read replica × 8 vCPU · 32 GB · 500 GB SSD | Chưa cần |
| Đích gọi | HTTP endpoint của các service khác - × Ký HMAC, timeout 30 s | Chưa cần | Chưa cần |
| Cảnh báo | Slack webhook / email 1 × Dịch vụ bên ngoài | Chưa cần | Chưa cần |
| Giám sát | Cloud Monitoring + log 1 × Managed | Prometheus + Grafana, PagerDuty 1 × Managed | Chưa cần |
| API | Chưa cần | Node.js (NestJS), container autoscale 2 × 2 vCPU · 4 GB RAM | Node.js trên Kubernetes, 3 AZ 6 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Poller | Chưa cần | Node.js, SKIP LOCKED theo lô 500 2 × 1 vCPU · 2 GB RAM | Chưa cần |
| Queue | Chưa cần | Amazon SQS / Google Pub/Sub 1 queue thường + 1 queue khẩn + DLQ × Managed, visibility timeout 60 s | Chưa cần |
| Worker | Chưa cần | Node.js consumer, scale theo độ dài queue 2–3 (autoscale) + 2 cho hàng khẩn × 2 vCPU · 4 GB RAM, 200 lời gọi song song | Node.js consumer trên Kubernetes 20–139 pod (scale theo lịch + lag) × 2 vCPU · 4 GB mỗi pod |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| Lịch + trạng thái run | Chưa cần | Chưa cần | ScyllaDB (hoặc Cassandra), RF 3 15 node × 8 vCPU · 64 GB · 2 TB NVMe mỗi node |
| Dispatcher | Chưa cần | Chưa cần | Go/Node service giữ timing wheel 16 pod × 2 vCPU · 4 GB mỗi pod, 64 shard mỗi pod |
| Điều phối shard | Chưa cần | Chưa cần | ZooKeeper / etcd 3 node × 2 vCPU · 4 GB mỗi node |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 6 broker × Topic theo độ ưu tiên, 192 partition, RF 3 |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache Redis Cluster 3 shard × (1 primary + 1 replica) × 2 vCPU · 8 GB mỗi node |
| Lưu trữ lâu dài | Chưa cần | Chưa cần | S3 + Parquet 1 × Lịch sử > 7 ngày |
Đào sâu
Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.
1) Lưu lịch thế nào để mỗi giây tìm ra đúng những job đến hạn mà không quét toàn bộ?
Cron chạy mỗi phút, SELECT * FROM jobs rồi kiểm tra trong code xem biểu thức cron có khớp phút hiện tại không. Chi phí tỉ lệ với tổng số job, kể cả job chỉ chạy mỗi tháng một lần.
Đánh đổi: Ổn với vài nghìn job; với 10 triệu job thì một lượt quét đã dài hơn một phút và độ chính xác chỉ tới phút.
⚠ Chưa có nguồn trong thư viện: quét toàn bảng là nhận định chung
Mỗi job chỉ có một run SCHEDULED kế tiếp. Poller hỏi WHERE status='SCHEDULED' AND scheduled_at <= now() ORDER BY scheduled_at LIMIT 500 FOR UPDATE SKIP LOCKED, nên nhiều poller chạy song song mà không lấy trùng. Index một phần (partial index) chỉ trên hàng SCHEDULED giữ index nhỏ.
Đánh đổi: Một Postgres primary gánh hết lượt ghi trạng thái; ổn tới vài nghìn run/s, sau đó primary và index thành điểm nghẽn.
Nguồn: Grokking Modern System Design · ch.23 ↗ · mục "Components" (tasks lưu trong RDB, batching và prioritization) ; Chưa có nguồn trong thư viện: SKIP LOCKED là kỹ thuật DB chung
Partition key là (bucket_phút, shard 0..1023), clustering theo scheduled_at, run_id. Đọc job đến hạn là đọc đúng những partition của phút hiện tại, chi phí tỉ lệ với số job đến hạn chứ không tỉ lệ với tổng số job. Mỗi dispatcher sở hữu một nhóm shard, đọc trước bucket của 1-2 phút tới vào một timing wheel hoặc Redis ZSET (score = thời điểm chạy, cách làm delayed task trong Redis in Action §6.4.2) rồi phát đúng giây.
Đánh đổi: Phải có cơ chế giao shard cho dispatcher và dựng lại bộ đệm khi dispatcher chết; sửa lịch phải xóa đúng hàng trong bucket cũ.
Nguồn: Redis in Action ↗ · ch.6 · §6.4.2 Delayed tasks (tr. 137) ; Chưa có nguồn trong thư viện: bucket thời gian trên Cassandra và timing wheel không có trong sách
2) Worker có thể chết giữa chừng hoặc mất ack. Làm sao mỗi job chỉ có hiệu ứng một lần?
Worker lấy message, xóa khỏi queue, rồi mới gọi đích. Không bao giờ chạy trùng.
Đánh đổi: Worker crash sau khi xóa là job mất hẳn, vi phạm yêu cầu không mất job.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Message deletion" (visibility_timeout, dead-letter queue)
Worker chỉ xóa message sau khi đích trả 2xx. Worker chết thì hết visibility timeout message hiện lại cho worker khác (Grokking ch.17). Không mất job.
Đánh đổi: Job chạy lâu hơn timeout hoặc ack bị mất thì bị gọi hai lần; worker cũ (zombie) vẫn có thể ghi đè trạng thái.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Message deletion" (visibility_timeout, dead-letter queue)
Nhận việc bằng UPDATE run SET status='RUNNING', attempt=attempt+1, lease_until=now()+30s WHERE run_id=? AND (status='QUEUED' OR lease_until < now()) RETURNING attempt. Tác vụ dài gửi heartbeat để gia hạn lease. Ghi kết quả có điều kiện WHERE attempt = attempt_của_tôi, nên worker cũ đã mất lease không ghi đè được. Đích nhận run_id làm idempotency key và tự bỏ qua lần gọi trùng (Grokking ch.23 Task idempotency, Microservices Patterns §3.3.6).
Đánh đổi: Scheduler chỉ đảm bảo giao ít nhất một lần; hiệu ứng đúng một lần cần đích hợp tác lưu run_id đã xử lý.
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Task idempotency" ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95)
3) Thiết kế retry và DLQ thế nào để không gây bão retry và không lặp vô hạn?
Gọi lỗi thì gọi lại liền, không giới hạn số lần. Worker bị chiếm chỗ suốt thời gian đó.
Đánh đổi: Đích đang sập bị dội thêm tải gấp nhiều lần, job hỏng vĩnh viễn (poison job) chiếm worker mãi mãi.
⚠ Chưa có nguồn trong thư viện: retry ngay là nhận định chung
Lần thử thứ n được hẹn sau 2^n × 10 s cộng ngẫu nhiên, ghi lại vào store với scheduled_at mới nên worker được giải phóng. Quá max_attempts thì chuyển DEAD.
Đánh đổi: Vẫn retry cả lỗi chắc chắn lặp lại (payload sai, 400) và chưa bảo vệ đích khi hàng nghìn job cùng lỗi.
Nguồn: Grokking Modern System Design · ch.23 ↗ · mục "Components" (retry cho số lần cho phép) ; Chưa có nguồn trong thư viện: backoff mũ có jitter không có trong sách
4xx là lỗi cố định nên vào DLQ ngay; 5xx, 429, timeout thì backoff có jitter. DLQ lưu payload, lỗi cuối và lịch sử attempt; chủ job xem và redrive sau khi sửa (Grokking ch.17 dead-letter queue). Khi tỉ lệ lỗi của một target vượt 50% trong 1 phút thì mở circuit: tạm hoãn mọi run của target đó thay vì đốt lượt thử, rồi thả thử vài run để kiểm tra.
Đánh đổi: Thêm trạng thái và công cụ vận hành; run bị hoãn vì circuit mở có thể vượt delay tolerance và cần cảnh báo riêng.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Message deletion" (visibility_timeout, dead-letter queue) ; Building Microservices ↗ (.zip) · ch.11 · mục "Circuit Breakers"
4) 50 triệu job cùng hẹn lúc 0h00. Làm sao vẫn đúng giờ và scale worker kịp?
Poller là một tiến trình, đọc tuần tự rồi đẩy vào queue. Worker chỉ tăng khi CPU đã cao.
Đánh đổi: Poller là single point of failure, một mình đọc 50 triệu hàng mất hàng chục phút; worker mới lên sau 1-3 phút, lúc đó hàng đợi đã dồn.
⚠ Chưa có nguồn trong thư viện: một poller duy nhất là nhận định chung
Chia run theo shard, mỗi shard một poller giữ lease trong ZooKeeper/etcd, poller chết thì poller khác nhận shard. Worker autoscale theo số message đang chờ (lag) thay vì CPU.
Đánh đổi: Đỉnh vẫn ập tới trong 1 giây nên lag tăng vọt trước khi autoscale phản ứng; job khẩn cấp phải xếp hàng chung với job báo cáo.
Nguồn: Grokking Modern System Design · ch.23 ↗ · mục "Task submitter" (cluster manager, heartbeat) ; Chưa có nguồn trong thư viện: leader election bằng ZooKeeper: xem bài /hoc/zookeeper của khoá học
Số run trong bucket 0h00 đã biết từ trước nên scale worker theo lịch 5 phút trước giờ đỉnh. Run được đẩy vào các hàng theo delay tolerance (Grokking ch.23 Prioritization): job khẩn (OTP, cảnh báo bảo mật) có hàng và worker riêng nên luôn < 5 s; job chịu trễ được dàn đều trong cửa sổ tolerance thay vì bắn cùng giây. Thêm rate limit theo target để không đè sập dịch vụ đích, và fair share theo tenant để một tenant không chiếm hết worker.
Đánh đổi: Phải yêu cầu client khai báo delay tolerance và độ ưu tiên trung thực (có thể gắn với chi phí); scale trước nghĩa là trả tiền cho tài nguyên vài phút chưa dùng.
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Prioritization" (delay tolerance) ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Types of throttling"
Trong phỏng vấn
- “Tôi tách lưu lịch (store bền, chỉ mục theo thời gian) khỏi phân phối (queue) và thực thi (worker), mỗi phần scale riêng.”
- “Scheduler đảm bảo giao ít nhất một lần; tôi dùng lease có fencing token và gửi run_id cho đích để loại trùng.”
- “Với job cron tôi chỉ sinh sẵn một run kế tiếp, nên đổi lịch hay xóa job rất rẻ.”
- “Tải giờ đỉnh như 0h00 biết trước từ dữ liệu bucket, nên tôi scale theo lịch và dàn job theo delay tolerance.”
- Hứa exactly-once mà không nói đích phải idempotent.
- Quét toàn bảng job mỗi phút để kiểm tra biểu thức cron.
- Retry ngay lập tức không giới hạn, không có DLQ.
- Một poller duy nhất, không có cơ chế giao lại shard khi nó chết.
Đọc thêm
Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.