Scheduler, retry, timeout, tránh chạy trùngSchedulers, retries, timeouts, no double runs
Bảng job trong PostgreSQL vừa là hàng đợi vừa là nguồn sự thật. Worker nhận job bằng SKIP LOCKED và giữ lease; Lease Reaper trả lại job có lease hết hạn. Bật “Worker chết” hoặc “Quá timeout” để xem cách hệ thống tự hồi phục.A job table in PostgreSQL is both the queue and the source of truth. Workers claim jobs with SKIP LOCKED and hold a lease; the Lease Reaper returns jobs whose lease expired. Turn on “Worker crashes” or “Timeout” to see how the system recovers.
1Service đơn hàng đưa job gửi hoá đơn vào hàng đợi trong cùng transaction tạo đơn.The order service enqueues a send-invoice job in the same transaction that creates the order.
SELECT … FOR UPDATE SKIP LOCKED trong cùng câu UPDATE đổi trạng thái sang RUNNING: worker này bỏ qua dòng worker kia đang khoá, không chờ, không trùng.SELECT … FOR UPDATE SKIP LOCKED inside the UPDATE that sets RUNNING: each worker skips rows another one holds, with no waiting and no duplicates.
Mỗi job có lease locked_until. Worker gia hạn bằng heartbeat; reaper trả job có lease hết hạn về QUEUED. Lúc ghi kết quả phải kiểm locked_by để worker cũ không ghi đè.Each job has a lease, locked_until. The worker extends it with heartbeats; a reaper returns expired leases to QUEUED. Check locked_by when writing the result so a stale worker cannot overwrite it.
Mỗi lần chạy cron ghi một dòng với unique(job_name, scheduled_for); instance nào INSERT được thì chạy, instance khác nhận lỗi trùng và bỏ qua. Hoặc dùng leader election (advisory lock, ShedLock).Each cron firing inserts a row with unique(job_name, scheduled_for); the instance whose INSERT succeeds runs it, the others hit the unique violation and skip. Or use leader election (an advisory lock, ShedLock).
Mỗi job chạy trong context có deadline; mọi I/O nhận context đó nên bị huỷ đúng lúc. Ở Java, Future.get(timeout) rồi cancel(true) và handler phải tôn trọng interrupt.Every job runs under a context with a deadline; all I/O takes that context and is cancelled on time. In Java, Future.get(timeout) then cancel(true), and the handler must honour interrupts.
Lỗi tạm thời được hẹn lại với backoff có jitter; lỗi dữ liệu hoặc hết số lần thử chuyển sang DEAD để người xem, không thử mãi.Transient errors are rescheduled with jittered backoff; bad data or exhausted attempts go to DEAD for a person to review, never retried forever.
Job có thể chạy hơn một lần sau crash hoặc timeout. Handler dùng idempotency key với bên ngoài và kiểm tra trạng thái trước khi ghi.A job can run more than once after a crash or a timeout. Handlers pass an idempotency key to external systems and check state before writing.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
job | id, type, key, payload, status, priority, run_at, attempts, max_attempts, timeout_s, locked_by, locked_until, last_error, created_at | QUEUED → RUNNING → DONE · QUEUED (retry) · DEAD; index (status, priority, run_at); unique(key)QUEUED → RUNNING → DONE · QUEUED (retry) · DEAD; index (status, priority, run_at); unique(key) |
cron_run | job_name, scheduled_for, instance_id, job_id, created_at | unique(job_name, scheduled_for): mỗi lần hẹn chỉ một instance tạo jobunique(job_name, scheduled_for): one instance creates the job per firing |
job_attempt | job_id, attempt, worker_id, started_at, finished_at, outcome, error | Lịch sử từng lần chạy, hiển thị trên dashboardEvery run, shown on the dashboard |
Worker nhận job theo số luồng còn trống, chạy handler trong giới hạn thời gian, rồi ghi DONE, hẹn lại hoặc DEAD.The worker claims as many jobs as it has free slots, runs the handler within a time limit, then records DONE, a retry or DEAD.
@Component
@RequiredArgsConstructor
public class JobWorker {
private static final String CLAIM = """
UPDATE job SET status = 'RUNNING', locked_by = ?, attempts = attempts + 1,
locked_until = now() + interval '60 seconds'
WHERE id IN (SELECT id FROM job
WHERE status = 'QUEUED' AND run_at <= now()
ORDER BY priority DESC, run_at
LIMIT ? FOR UPDATE SKIP LOCKED)
RETURNING *""";
private final JdbcTemplate jdbc;
private final JobRepository jobs;
private final Map<String, JobHandler> handlers;
private final ThreadPoolTaskExecutor slots; // core = max = 8
private final ExecutorService handlerPool;
private final String workerId;
@Scheduled(fixedDelay = 1000)
public void poll() {
int free = slots.getMaxPoolSize() - slots.getActiveCount();
if (free <= 0) return;
List<Job> batch = jdbc.query(CLAIM, JOB_MAPPER, workerId, free);
batch.forEach(job -> slots.execute(() -> run(job)));
}
void run(Job job) {
Future<?> f = handlerPool.submit(() -> handlers.get(job.type()).handle(job));
try {
f.get(job.timeoutSeconds(), TimeUnit.SECONDS);
jobs.markDone(job.id(), workerId); // WHERE locked_by = ?
} catch (TimeoutException e) {
f.cancel(true); // interrupt the handler
jobs.retryOrDead(job, workerId, e);
} catch (Exception e) {
jobs.retryOrDead(job, workerId, e); // run_at = now() + backoff
}
}
}func (w *Worker) Run(ctx context.Context, slots int) {
var wg sync.WaitGroup
for i := 0; i < slots; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
if err := w.runOne(ctx); errors.Is(err, ErrNoJob) {
time.Sleep(time.Second)
}
}
}()
}
wg.Wait() // graceful shutdown: running jobs finish first
}
func (w *Worker) runOne(ctx context.Context) error {
job, err := w.store.Claim(ctx, w.id, 60*time.Second) // FOR UPDATE SKIP LOCKED
if err != nil {
return err
}
jobCtx, cancel := context.WithTimeout(ctx, job.Timeout)
defer cancel()
go w.heartbeat(jobCtx, cancel, job.ID)
err = w.handlers[job.Type].Handle(jobCtx, job)
switch {
case err == nil:
return w.store.MarkDone(ctx, job.ID, w.id)
case job.Attempts >= job.MaxAttempts:
return w.store.MarkDead(ctx, job.ID, w.id, err)
default:
delay := backoff(job.Attempts) // 10s·2^n + jitter, capped at 1h
return w.store.Reschedule(ctx, job.ID, w.id, time.Now().Add(delay), err)
}
}
func (w *Worker) heartbeat(ctx context.Context, cancel context.CancelFunc, id int64) {
t := time.NewTicker(20 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := w.store.ExtendLease(ctx, id, w.id, 60*time.Second); err != nil {
cancel() // lease lost: stop the handler too
return
}
}
}
}| Bảng job (Postgres)Job table (Postgres) | Broker (RabbitMQ, SQS)Broker (RabbitMQ, SQS) | |
|---|---|---|
| Hợp vớiGood for | Dưới vài trăm job/giây, cần truy vấn và sửa jobUp to a few hundred jobs/s, when you need to query and edit jobs | Lưu lượng lớn, nhiều consumerHigh volume, many consumers |
| Giao dịchTransactions | Enqueue cùng transaction với dữ liệu nghiệp vụEnqueue in the same transaction as business data | Cần outbox để không mất jobNeeds an outbox to avoid losing jobs |
| Hẹn giờ, hiển thịScheduling, visibility | run_at và dashboard chỉ là câu SQLrun_at and the dashboard are plain SQL | Delay giới hạn, khó xem từng jobLimited delays, individual jobs are hard to inspect |
| Chọn khiPick it when | Đã có Postgres, team nhỏPostgres is already there and the team is small | Bảng job làm nghẽn DB chínhThe job table starts to load the main database |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Không có heartbeat thì reaper trả job về QUEUED sau 60 giây và worker khác chạy song song. Phải gia hạn lease định kỳ, và khi gia hạn thất bại thì huỷ handler.Without heartbeats the reaper requeues it after 60 seconds and another worker runs it in parallel. Extend the lease periodically and cancel the handler when an extension fails.
Worker cũ có thể bị dừng lâu (GC, mạng) rồi tỉnh lại sau khi job đã được trao cho worker khác. Điều kiện locked_by đóng vai fencing token, chặn ghi đè kết quả.A stale worker can pause (GC, network) and wake up after the job was handed to someone else. The locked_by condition acts as a fencing token and blocks the overwrite.
Mỗi instance INSERT vào cron_run(job_name, scheduled_for='02:00'); unique constraint để một instance thắng. Hoặc giữ pg_try_advisory_lock làm leader.Each instance inserts into cron_run(job_name, scheduled_for='02:00'); the unique constraint lets one instance win. Or hold pg_try_advisory_lock as the leader.
Job chủ yếu chờ I/O thì nhiều luồng hơn số CPU, nhưng tổng luồng trên mọi instance không được vượt connection pool của DB và giới hạn của API phía sau.I/O-bound jobs allow more slots than CPUs, but the total across instances must stay under the DB connection pool and the downstream API's limits.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.