Harbor

Lập lịch tác vụ phân tán

KhóHàng đợi hẹn giờLease & idempotencyRetry & DLQĐúng giờ ở quy mô lớnGiống Cron phân tán, Quartz, Airflow scheduler, AWS EventBridge Scheduler

Hiểu đề

Lập lịch tác vụ phân tán là gì? Dịch vụ dùng chung cho các team trong công ty: đăng ký một tác vụ chạy một lần vào lúc X hoặc lặp lại theo biểu thức cron, ví dụ gửi nhắc gia hạn lúc 9h sáng hay chốt báo cáo lúc 0h. Scheduler lo việc gọi đúng giờ, chạy lại khi lỗi và báo cho chủ tác vụ khi hỏng hẳn.

Yêu cầu chức năng

  1. 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)
  2. Hệ thống gọi đích của job khi đến giờ, truyền kèm run_id làm idempotency key
  3. 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
  4. 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

Job
Định nghĩa tác vụ: tenant, loại (ONCE/CRON), cron_expr, timezone, target, payload, retry_policy, delay_tolerance, trạng thái ACTIVE/PAUSED.
JobRun
Một lần chạy cụ thể: run_id, job_id, scheduled_at, attempt, status (SCHEDULED/QUEUED/RUNNING/SUCCEEDED/RETRY/DEAD), lease_owner, lease_until.
TimeBucket
Chỉ mục theo thời gian: (phút, shard) → danh sách run đến hạn trong phút đó. Giúp tìm job đến hạn mà không quét toàn bảng.
Target
Đích gọi: URL + chữ ký HMAC, hoặc tên topic; có timeout và giới hạn tốc độ riêng.
DeadLetter
Lần chạy đã hết số lần thử: lỗi cuối, toàn bộ lịch sử attempt, để chủ job xem và redrive.
MethodEndpointGhi chú
POST/v1/jobsTạ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}/heartbeatDà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ẻ.

  1. 1Client gọi POST /v1/jobs với Idempotency-Key
  2. 2API kiểm tra cron hợp lệ, tính next_run_at theo múi giờ của job
  3. 3Trong 1 transaction: INSERT Job và INSERT JobRun đầu tiên (status SCHEDULED, scheduled_at = next_run_at)
  4. 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.

  1. 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. 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
  3. 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
  4. 4Worker gọi đích với header Idempotency-Key = run_id, chờ tối đa timeout của target
  5. 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. 1Đích trả 5xx hoặc timeout: status RETRY, scheduled_at = now() + backoff (2^attempt × 10 s, cộng jitter)
  2. 2Đích trả 4xx (trừ 429): lỗi cố định, chuyển thẳng sang DEAD
  3. 3attempt vượt max_attempts: status DEAD, ghi DeadLetter, gửi cảnh báo cho chủ job
  4. 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.

  1. 1GET /v1/jobs/{id}/runs đọc từ read replica, index (job_id, scheduled_at DESC)
  2. 2Partition theo ngày, DROP partition cũ thay vì DELETE từng hàng
  3. 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.

Người dùng
1 sản phẩm ~10.000 DAU, 50.000 lần chạy/ngày
Tải đỉnh
≈ 83 lần chạy/s khi 5.000 job hẹn đúng 09:00
Chi phí ước lượng
≈ 60–150 USD/tháng (ước lượng)

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.

Người dùngService gọiDịch vụScheduler APIDatabasePostgreSQLWorkerPoller + WorkerBên ngoàiĐích (HTTP)Bên ngoàiSlack / Emailtạo jobINSERT job + runSKIP LOCKEDgọi + run_idrun DEAD

Ướ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ầnChọnSố lượngCấu hìnhVì sao
Scheduler serviceNode.js (NestJS): API + poller + worker trong cùng tiến trình22 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.
DatabasePostgreSQL managed12 vCPU · 8 GB · 50 GB SSD250 ghi/s lúc đỉnh và 1,5 GB lịch sử là nhỏ; partial index trên run SCHEDULED.
Đích gọiHTTP endpoint của các service khác-Ký HMAC, timeout 30 sScheduler chỉ gọi, logic nằm ở đích.
Cảnh báoSlack webhook / email1Dịch vụ bên ngoàiBáo chủ job khi run vào DEAD.
Giám sátCloud Monitoring + log1ManagedTheo dõi số run trễ và tỉ lệ lỗi là đủ ở mức này.
Thay đổi so với mức trước
  • Đ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
Nút thắt tiếp theo

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ầnMức 1Mức 2Mức 3
Người dùng1 sản phẩm ~10.000 DAU, 50.000 lần chạy/ngàyNề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àyNề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ầnChư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ầnChưa cần
Cảnh báo
Slack webhook / email
1 × Dịch vụ bên ngoài
Chưa cầnChưa cần
Giám sát
Cloud Monitoring + log
1 × Managed
Prometheus + Grafana, PagerDuty
1 × Managed
Chưa cần
APIChư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
PollerChưa cần
Node.js, SKIP LOCKED theo lô 500
2 × 1 vCPU · 2 GB RAM
Chưa cần
QueueChư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
WorkerChư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
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 4 GB
Chưa cần
Lịch + trạng thái runChưa cầnChưa cần
ScyllaDB (hoặc Cassandra), RF 3
15 node × 8 vCPU · 64 GB · 2 TB NVMe mỗi node
DispatcherChưa cầnChư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 shardChưa cầnChưa cần
ZooKeeper / etcd
3 node × 2 vCPU · 4 GB mỗi node
KafkaChưa cầnChưa cần
MSK / Confluent
6 broker × Topic theo độ ưu tiên, 192 partition, RF 3
Redis ClusterChưa cầnChư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àiChưa cầnChư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ộ?

Cách chưa tốt
Mỗi phút đọc toàn bộ bảng jobs và tính cron cho từng job

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

Cách tốt
Bảng job_runs có scheduled_at + index B-tree, poller dùng SKIP LOCKED

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

Cách tốt nhất
Bảng chia theo time bucket (phút, shard) trên Cassandra/ScyllaDB + hàng đợi hẹn giờ trong bộ nhớ cho vài phút tới

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?

Cách chưa tốt
Ack message ngay khi nhận (at-most-once)

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)

Cách tốt
At-least-once bằng visibility timeout của 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)

Cách tốt nhất
Lease có fencing token trong DB + idempotency key ở đích

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?

Cách chưa tốt
Retry ngay lập tức trong vòng lặp của worker

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

Cách tốt
Backoff mũ có jitter, giới hạn max_attempts, hẹn lại như run mới

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

Cách tốt nhất
Phân loại lỗi + DLQ có redrive + circuit breaker theo target

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?

Cách chưa tốt
Một poller duy nhất, worker autoscale theo CPU

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

Cách tốt
Nhiều poller theo shard có leader election + worker scale theo độ dài queue

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

Cách tốt nhất
Biết trước tải nên scale trước, tách hàng theo độ ưu tiên và dàn job theo delay tolerance

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

Nên nói
  • “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.”
Bẫy hay gặp
  • 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

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.
Kafka
Kafka là log phân tán bền vững: topic chia partition, consumer đọc theo offset và có thể đọc lại. Bài này đi qua thứ tự theo partition, consumer group, replication với ISR và acks, giới hạn của exactly-once, cách chọn số partition và so sánh với SQS, Pub/Sub, RabbitMQ.
Cassandra
Cassandra là database wide-column phân tán, ghi rất nhanh nhờ LSM tree, mở rộng gần tuyến tính bằng ring và consistent hashing. Bài này giải thích partition key, clustering column, cách mô hình hoá theo câu truy vấn, tunable consistency và những cái bẫy như tombstone.
ZooKeeper
ZooKeeper là dịch vụ điều phối cho hệ phân tán: bầu leader, lưu cấu hình, service discovery và distributed lock dựa trên cây znode, node ephemeral và sequential, đồng thuận bằng ZAB. Bài này giải thích cách nó hoạt động, vì sao Kafka chuyển sang KRaft, và khi nào chọn etcd hay Consul.
Redis
Redis là kho key-value trong RAM với nhiều cấu trúc dữ liệu: dùng làm cache, bộ đếm, leaderboard, hàng đợi, GEO và pub/sub. Bài này đi qua mô hình dữ liệu, persistence, replication, Cluster, Lua và cách tính RAM.
PostgreSQL
PostgreSQL là lựa chọn mặc định an toàn cho phần lớn hệ thống: ACID, index phong phú, replication, partitioning và hệ extension mạnh. Bài này giải thích MVCC, các loại index, mức isolation, replication, connection pooling, bảng sizing theo cấu hình và dấu hiệu nên rời một node Postgres.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.