8 phút

Mô hình Cron + Database: tác vụ nền theo lịch không cần hàng đợi

Tìm hiểu mô hình cron + database để chạy các công việc nền theo lịch với cơ chế thử lại, khóa và idempotency — mà không phải triển khai hệ thống hàng đợi đầy đủ.

Mô hình Cron + Database: tác vụ nền theo lịch không cần hàng đợi

Vấn đề: chạy công việc theo lịch mà không thêm hạ tầng

Hầu hết ứng dụng cần có công việc xảy ra sau đó, hoặc theo lịch: gửi email nhắc, kiểm tra thanh toán hàng đêm, dọn bản ghi cũ, xây lại báo cáo, hoặc làm mới cache.

Ban đầu, dễ bị cám dỗ để thêm một hệ thống hàng đợi đầy đủ vì trông như cách “đúng” để làm tác vụ nền. Nhưng hàng đợi thêm nhiều thành phần phải vận hành: một dịch vụ khác để chạy, giám sát, triển khai và gỡ lỗi. Với một đội nhỏ (hoặc người sáng lập đơn lẻ), trọng lượng thêm này có thể làm chậm bạn.

Vậy câu hỏi thực sự là: làm sao chạy công việc theo lịch một cách đáng tin cậy mà không phải dựng thêm hạ tầng?

Một cách thử đầu tiên phổ biến là đơn giản: thêm một cron gọi một endpoint, và endpoint đó làm công việc. Nó hoạt động cho đến khi không nữa. Khi bạn có nhiều hơn một server, deploy vào nhầm thời điểm, hoặc một job chạy lâu hơn dự kiến, bạn bắt đầu thấy lỗi khó hiểu.

Công việc theo lịch thường hỏng theo vài cách có thể dự đoán:

  • Chạy đôi: hai server cả hai chạy cùng một task, dẫn đến hóa đơn tạo hai lần hoặc email gửi hai lần.
  • Bị mất: một lần gọi cron thất bại khi deploy và không ai để ý cho tới khi người dùng phàn nàn.
  • Lỗi im lặng: job lỗi một lần rồi không chạy lại vì không có kế hoạch thử lại.
  • Làm dở: job crash giữa chừng và để dữ liệu ở trạng thái lạ.
  • Không có dấu vết: bạn không thể trả lời “lần cuối chạy khi nào?” hoặc “đêm qua xảy ra gì?”.

Mô hình cron + database là con đường giữa. Bạn vẫn dùng cron để “đánh thức” theo lịch, nhưng bạn lưu ý định job và trạng thái job trong cơ sở dữ liệu để hệ thống có thể phối hợp, thử lại và ghi lại những gì đã xảy ra.

Nó phù hợp khi bạn đã có một cơ sở dữ liệu (thường là PostgreSQL), một số ít loại job, và bạn muốn hành vi dễ dự đoán với ít công việc vận hành. Nó cũng là lựa chọn tự nhiên cho ứng dụng xây nhanh trên stack hiện đại (ví dụ, React + Go + PostgreSQL).

Nó không phù hợp khi bạn cần thông lượng rất cao, job chạy lâu cần phát trực tiếp tiến độ, thứ tự nghiêm ngặt giữa nhiều loại job, hoặc fan-out nặng (hàng nghìn sub-task mỗi phút). Trong những trường hợp đó, một hàng đợi thực thụ và worker chuyên dụng thường đáng đầu tư hơn.

Ý tưởng cốt lõi bằng ngôn ngữ thông thường

Mô hình cron + database chạy công việc nền theo lịch mà không cần hệ thống hàng đợi đầy đủ. Bạn vẫn dùng cron (hoặc bất kỳ scheduler nào), nhưng cron không quyết định phải chạy gì. Nó chỉ đánh thức worker thường xuyên (một lần mỗi phút là phổ biến). Cơ sở dữ liệu quyết định công việc nào đến hạn và đảm bảo chỉ có một worker nhận mỗi job.

Hãy nghĩ nó như một bảng kiểm chung trên whiteboard. Cron là người vào phòng mỗi phút và hỏi: “Có ai cần làm gì không?” Cơ sở dữ liệu là whiteboard cho biết cái nào đến hạn, cái nào đã bị ai đó nhận, và cái nào đã xong.

Các thành phần đơn giản:

  • Một trigger lịch chạy thường xuyên.
  • Một bảng jobs chứa “cái gì” và “khi nào” (thời điểm đến hạn), cùng trạng thái và số lần thử.
  • Một hoặc nhiều worker poll bảng, claim một job và thực hiện công việc.
  • Việc claim dùng khóa trong cơ sở dữ liệu để hai worker không lấy cùng một hàng.
  • Cơ sở dữ liệu là nguồn sự thật cho cái đã chạy, cái đã lỗi, và cái cần thử lại.

Ví dụ: bạn muốn gửi nhắc hóa đơn mỗi sáng, làm mới cache mỗi 10 phút, và dọn session cũ hàng đêm. Thay vì ba cron riêng (mỗi cron có nguy cơ chồng lấn và chế độ lỗi riêng), bạn lưu các entry job ở một chỗ. Cron khởi động cùng một quá trình worker. Worker hỏi Postgres: “Cái nào đến hạn bây giờ?” và Postgres trả lời bằng cách cho worker claim an toàn đúng một job tại một thời điểm.

Điều này mở rộng dần. Bạn có thể bắt đầu với một worker trên một server. Sau này, chạy năm worker trên nhiều server. Hợp đồng vẫn vậy: bảng là hợp đồng.

Suy nghĩ thay đổi: cron chỉ là tiếng chuông đánh thức. Cơ sở dữ liệu là cảnh sát giao thông quyết định được phép chạy gì, ghi lại điều đã xảy ra, và cho bạn lịch sử rõ ràng khi có sự cố.

Thiết kế bảng jobs (một schema thực tế)

Mô hình này hiệu quả nhất khi cơ sở dữ liệu trở thành nguồn sự thật cho việc gì nên chạy, khi nào nên chạy, và lần gần nhất đã xảy ra điều gì. Schema không phức tạp, nhưng các chi tiết nhỏ (trường khóa và index phù hợp) tạo khác biệt khi tải tăng.

Một bảng hay hai?

Hai cách tiếp cận phổ biến:

  • Một bảng kết hợp khi bạn chỉ quan tâm trạng thái mới nhất của mỗi job (đơn giản, ít join).
  • Hai bảng khi bạn muốn tách rõ giữa “job này là gì” và “mỗi lần nó chạy” (lịch sử rõ ràng hơn, dễ debug hơn).

Nếu bạn dự đoán sẽ debug lỗi thường xuyên, giữ lịch sử. Nếu muốn thiết lập nhỏ nhất, bắt đầu với một bảng rồi thêm lịch sử sau.

Một schema thực tế (phiên bản hai bảng)

Dưới đây là bố cục thân thiện với PostgreSQL. Nếu bạn xây bằng Go với PostgreSQL, các cột này map tốt vào struct.

-- What should exist (the definition)
create table job_definitions (
  id            bigserial primary key,
  job_type      text not null,
  payload       jsonb not null default '{}'::jsonb,
  schedule      text,                      -- optional: cron-like text if you store it
  max_attempts  int not null default 5,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now()
);

-- What should run (each run / attempt group)
create table job_runs (
  id            bigserial primary key,
  definition_id bigint references job_definitions(id),
  job_type      text not null,
  payload       jsonb not null default '{}'::jsonb,
  run_at        timestamptz not null,
  status        text not null,             -- queued | running | succeeded | failed | dead
  attempts      int not null default 0,
  max_attempts  int not null default 5,

  locked_by     text,
  locked_until  timestamptz,

  last_error    text,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now()
);

Một vài chi tiết giúp đỡ sau này:

  • Giữ job_type là một chuỗi ngắn để định tuyến (ví dụ send_invoice_emails).
  • Lưu payload dưới dạng jsonb để dễ mở rộng mà không phải migrate.
  • run_at là “thời điểm đến hạn tiếp theo”. Cron (hoặc script scheduler) đặt nó, worker tiêu thụ nó.
  • locked_bylocked_until cho phép worker claim job mà không đụng độ nhau.
  • last_error nên ngắn và dễ đọc. Lưu stack trace ở nơi khác nếu cần.

Index bạn sẽ muốn

Không có index, worker sẽ phải scan quá nhiều. Bắt đầu với:

  • Index để tìm công việc đến hạn nhanh: (status, run_at)
  • Index để giúp phát hiện lock hết hạn: (locked_until)
  • Tùy chọn: index phân mảnh cho công việc active (ví dụ status trong queuedfailed)

Chúng giữ truy vấn “tìm job tiếp theo chạy được” nhanh ngay cả khi bảng lớn lên.

Khóa và claim job an toàn

Mục tiêu đơn giản: nhiều worker có thể chạy, nhưng chỉ một worker nên lấy một job cụ thể. Nếu hai worker xử lý cùng một hàng, bạn sẽ nhận email đôi, tính phí đôi, hoặc dữ liệu lộn xộn.

Cách an toàn là coi claim job như một “lease”. Worker đánh dấu job là locked trong cửa sổ thời gian ngắn. Nếu worker crash, lease hết hạn và worker khác có thể lấy nó. Đó là mục đích của locked_until.

Dùng lease để crash không chặn công việc mãi mãi

Nếu không có lease, worker có thể lock job và không bao giờ unlock (process bị kill, server reboot, deploy lỗi). Với locked_until, job lại có sẵn khi thời gian trôi qua.

Quy tắc phổ biến: job có thể được claim khi locked_untilNULL hoặc locked_until <= now().

Claim job bằng một cập nhật nguyên tử

Điểm quan trọng là claim job trong một câu lệnh đơn (hoặc một transaction). Bạn muốn database làm trọng tài.

Đây là pattern PostgreSQL phổ biến: chọn một job đến hạn, khóa nó, và trả nó về cho worker. (Ví dụ này dùng một bảng jobs; cùng ý áp dụng cho job_runs.)

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'queued'
    AND run_at <= now()
    AND (locked_until IS NULL OR locked_until <= now())
  ORDER BY run_at ASC
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
    locked_until = now() + interval '2 minutes',
    locked_by = $1,
    attempts = attempts + 1,
    updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;

Tại sao nó hoạt động:

  • FOR UPDATE SKIP LOCKED cho phép nhiều worker cạnh tranh mà không chặn nhau.
  • Lease được đặt khi claim, nên worker khác bỏ qua cho đến khi lease hết hạn.
  • RETURNING đưa hàng cho worker thắng cuộc.

Thời lượng lease nên là bao lâu, và làm sao gia hạn?

Đặt lease dài hơn thời gian chạy bình thường, nhưng đủ ngắn để crash phục hồi nhanh. Nếu hầu hết job xong trong 10 giây, lease 2 phút là dư.

Với task dài, gia hạn lease trong khi chạy (heartbeat). Cách đơn giản: mỗi 30 giây, kéo dài locked_until nếu bạn vẫn sở hữu job.

  • Độ dài lease: 5x đến 20x thời gian job điển hình
  • Khoảng heartbeat: 1/4 đến 1/2 lease
  • Cập nhật gia hạn nên có WHERE id = $job_id AND locked_by = $worker_id

Điều kiện cuối cùng quan trọng. Nó ngăn worker kéo dài lease của job mà nó không còn sở hữu.

Thử lại và backoff hoạt động có thể dự đoán

Thêm màn hình quản trị jobs
Tạo một trang nội bộ nhỏ để kiểm tra các job thất bại và dead một cách an toàn.

Thử lại là nơi mô hình này hoặc khiến bạn yên tâm hoặc biến thành mớ hỗn độn. Mục tiêu đơn giản: khi job lỗi, thử lại sau theo cách bạn có thể giải thích, đo lường và dừng.

Bắt đầu bằng cách làm trạng thái job rõ ràng và giới hạn: queued, running, succeeded, failed, dead. Trong thực tế, hầu hết đội dùng failed để nghĩa là “thất bại nhưng sẽ thử lại” và dead nghĩa là “thất bại và bỏ cuộc”. Phân biệt này ngăn vòng lặp vô hạn.

Đếm số lần thử là biện pháp bảo vệ thứ hai. Lưu attempts (đã thử bao nhiêu lần) và max_attempts (cho phép bao nhiêu lần). Khi worker bắt lỗi, nó nên:

  • tăng attempts
  • đặt trạng thái thành failed nếu attempts < max_attempts, nếu không thì dead
  • tính run_at cho lần thử tiếp theo (chỉ cho failed)

Backoff chỉ là quy tắc quyết định run_at kế tiếp. Chọn một, ghi lại, và giữ nhất quán:

  • Delay cố định: luôn chờ 1 phút
  • Lũy thừa: 1m, 2m, 4m, 8m
  • Lũy thừa với giới hạn: lũy thừa nhưng không quá, ví dụ 30m
  • Thêm jitter: làm ngẫu nhiên một chút để các job không đồng loạt thử lại cùng giây

Jitter quan trọng khi một phụ thuộc sập rồi hồi. Không có nó, hàng trăm job có thể thử lại cùng lúc và lại fail.

Lưu đủ chi tiết lỗi để làm cho việc gỡ lỗi dễ thấy. Bạn không cần hệ thống logging đầy đủ, nhưng cần những cơ bản:

  • last_error (thông điệp ngắn, an toàn để hiển thị trong admin)
  • error_code hoặc error_type (giúp nhóm lỗi)
  • failed_atnext_run_at
  • tùy chọn last_stack (chỉ nếu bạn quản lý kích thước)

Quy tắc cụ thể hoạt động tốt: đánh dấu job dead sau 10 lần thử, và backoff theo cấp số nhân với jitter. Điều này giữ cho lỗi tạm thời thử lại, nhưng ngăn job hỏng tiêu tốn CPU vô hạn.

Idempotency: ngăn trùng lặp ngay cả khi job lặp lại

Idempotency nghĩa là job của bạn có thể chạy hai lần mà vẫn cho kết quả cuối cùng giống nhau. Trong mô hình này, nó quan trọng vì cùng một hàng có thể bị lấy lại sau crash, timeout, hoặc retry. Nếu job của bạn là “gửi email hóa đơn”, chạy hai lần không phải vô hại.

Cách thực tế suy nghĩ: tách mỗi job thành (1) thực hiện công việc và (2) áp dụng hiệu ứng. Bạn muốn phần hiệu ứng chỉ xảy ra một lần, ngay cả khi phần thực hiện được thử nhiều lần.

Dùng idempotency key gắn với sự kiện nghiệp vụ

Idempotency key nên xuất phát từ cái job đại diện, không phải từ lần thử của worker. Key tốt là ổn định và dễ giải thích, như invoice_id, user_id + day, hoặc report_name + report_date. Nếu hai lần thử job tham chiếu cùng một sự kiện thực tế, chúng nên chia sẻ cùng key.

Ví dụ: “Tạo báo cáo doanh số hàng ngày cho 2026-01-14” có thể dùng sales_report:2026-01-14. “Thu tiền hóa đơn 812” có thể dùng invoice_charge:812.

Áp dụng "chỉ một lần" bằng ràng buộc database

Biện pháp đơn giản nhất là để PostgreSQL từ chối bản sao. Lưu idempotency key ở nơi có thể index, rồi thêm ràng buộc unique.

-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;

CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;

Điều này ngăn hai hàng có cùng key tồn tại cùng lúc. Nếu thiết kế bạn cho phép nhiều hàng (để lưu lịch sử), đặt unique trên bảng “effects” thay vào đó, như sent_emails(idempotency_key) hoặc payments(idempotency_key).

Các side effect phổ biến cần bảo vệ:

  • Email: tạo một hàng sent_emails với key duy nhất trước khi gửi, hoặc ghi lại message id của provider khi gửi xong.
  • Webhook: lưu delivered_webhooks(event_id) và bỏ qua nếu đã tồn tại.
  • Thanh toán: luôn dùng tính năng idempotency của nhà cung cấp thanh toán cộng với unique key trong DB của bạn.
  • Ghi file: ghi vào tên tạm, rồi rename, hoặc lưu bản ghi “file_generated” có key theo (type, date).

Nếu bạn xây trên stack Postgres-backed (ví dụ backend Go + PostgreSQL), các kiểm tra uniqueness này nhanh và dễ đặt gần dữ liệu. Ý tưởng chính: retry là bình thường, trùng lặp là thứ bạn kiểm soát.

Bước từng bước: xây worker và scheduler tối thiểu

Xây dựng worker Postgres
Mô tả bảng jobs và vòng lặp worker trong chat và tạo khung ứng dụng hoạt động.

Chọn một runtime nhàm chán và bám theo nó. Điểm của mô hình cron + database là ít thành phần, nên một process nhỏ bằng Go, Node, hoặc Python kết nối PostgreSQL thường là đủ.

Xây trong năm bước nhỏ

  1. Tạo bảng và index. Thêm bảng jobs (và các bảng lookup bạn cần sau), rồi index run_at, và thêm index giúp worker tìm job nhanh (ví dụ trên (status, run_at)).

  2. Viết hàm enqueue nhỏ. Ứng dụng của bạn chèn một hàng với run_at là “now” hoặc thời điểm tương lai. Giữ payload nhỏ và dự đoán được (IDs và job type, không phải blob lớn).

INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
  1. Cài vòng lặp claim. Chạy trong transaction. Chọn vài job đến hạn, khóa chúng để worker khác bỏ qua, và đánh dấu running trong cùng transaction.
WITH picked AS (
  SELECT id
  FROM jobs
  WHERE status = 'queued' AND run_at <= now()
  ORDER BY run_at
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
  1. Xử lý và kết thúc. Với mỗi job đã claim, thực hiện công việc, rồi cập nhật thành done với finished_at. Nếu fail, ghi lỗi và chuyển nó về queued với run_at mới (theo backoff). Giữ các cập nhật finalize nhỏ và luôn chạy chúng, ngay cả khi process sắp tắt.

  2. Thêm quy tắc retry dễ giải thích. Dùng công thức đơn giản như run_at = now() + (attempts^2) * interval '10 seconds', và dừng sau max_attempts bằng cách đặt status = 'dead'.

Thêm khả năng quan sát cơ bản

Bạn không cần dashboard đầy đủ ngày đầu, nhưng cần đủ để nhận ra vấn đề.

  • Log một dòng cho mỗi job: claimed, succeeded, failed, retried, dead.
  • Tạo query hoặc view đơn cho “dead jobs” và “running lâu”.
  • Alert khi số lượng lớn (ví dụ hơn N dead jobs trong giờ vừa qua).

Nếu bạn đã dùng stack Go + PostgreSQL, điều này map trực tiếp thành một binary worker duy nhất cộng cron.

Một ví dụ thực tế để bạn sao chép

Tưởng tượng một app SaaS nhỏ với hai việc theo lịch:

  • Dọn dẹp nightly xóa session hết hạn và file tạm.
  • Email "báo cáo hoạt động của bạn" hàng tuần gửi đến mỗi user vào sáng thứ Hai.

Giữ đơn giản: một bảng PostgreSQL chứa jobs, và một worker chạy mỗi phút (được cron kích hoạt). Worker claim job đến hạn, chạy, và ghi lại thành công hoặc lỗi.

Cái gì được enqueue, và khi nào

Bạn có thể enqueue jobs từ vài nơi:

  • Hàng ngày lúc 02:00: enqueue một job cleanup_nightly cho “hôm nay”.
  • Khi signup: enqueue một job send_weekly_report cho Monday tiếp theo của user.
  • Sau một sự kiện (ví dụ “user bấm Export report”): enqueue send_weekly_report chạy ngay cho khoảng ngày cụ thể.

Payload chỉ là tối thiểu worker cần. Giữ nhỏ để dễ retry.

{
  "type": "send_weekly_report",
  "payload": {
    "user_id": 12345,
    "date_range": {
      "from": "2026-01-01",
      "to": "2026-01-07"
    }
  }
}

Idempotency ngăn gửi đôi như thế nào

Worker có thể crash ở thời điểm tệ nhất: ngay sau khi gửi email nhưng trước khi đánh dấu job là “done”. Khi nó khởi động lại, nó có thể lấy lại cùng job.

Để ngăn gửi đôi, cho công việc một khóa dedupe tự nhiên và lưu nó nơi database có thể bắt buộc. Với báo cáo hàng tuần, key tốt là (user_id, week_start_date). Trước khi gửi, worker ghi “tôi sắp gửi báo cáo X”. Nếu bản ghi đó đã tồn tại, worker bỏ qua gửi.

Việc này có thể đơn giản như một bảng sent_reports với ràng buộc unique trên (user_id, week_start_date), hoặc một idempotency_key duy nhất trên chính job.

Một lỗi trông như thế nào (và cách phục hồi)

Ví dụ nhà cung cấp email timeout. Job fail, nên worker:

  • tăng attempts
  • lưu thông điệp lỗi để debug
  • lên lịch thử lại theo backoff (ví dụ: +1 min, +5 min, +30 min, +2 hours)

Nếu tiếp tục fail quá giới hạn (ví dụ 10 attempts), đánh dấu dead và dừng retry. Job hoặc sẽ thành công một lần, hoặc sẽ thử lại theo lịch rõ ràng, và idempotency làm cho retry an toàn.

Sai lầm phổ biến và bẫy

Nguyên mẫu cron runner của bạn
Biến mẫu này thành một worker được kích hoạt bởi cron chạy được nhanh chóng.

Mô hình cron + database đơn giản, nhưng sai nhỏ có thể biến nó thành trùng lặp, công việc kẹt, hoặc tải bất ngờ. Phần lớn vấn đề xuất hiện sau crash, deploy, hoặc spike traffic đầu tiên.

Sai lầm gây trùng lặp hoặc job kẹt

Hầu hết sự cố thực tế đến từ vài bẫy sau:

  • Chạy cùng job từ nhiều cron entry mà không có lease. Nếu hai server tick cùng phút, cả hai có thể claim cùng công việc trừ khi bước claim là nguyên tử và đặt lock (hoặc lease) trong cùng transaction.
  • Bỏ qua locked_until. Nếu worker crash sau khi claim job, hàng đó có thể ở trạng thái “in progress” mãi mãi. Timestamp lease cho phép worker khác lấy lại sau.
  • Thử lại ngay lập tức khi lỗi. Khi một API xuống, retry tức thì tạo spike, cháy rate limit, và lại fail. Luôn lên lịch thử lại vào tương lai.
  • Xem “ít nhất một lần” (at least once) như “chính xác một lần” (exactly once). Job có thể chạy hai lần (timeout, restart, network). Nếu lặp lại gây hại, làm cho side effects an toàn khi lặp lại.
  • Lưu payload lớn trong hàng job. JSON lớn làm phình bảng, làm chậm index, và khiến việc khóa nặng hơn. Lưu tham chiếu (ví dụ user_id, invoice_id, hoặc khoá file) rồi fetch khi chạy.

Ví dụ: gửi email hóa đơn hàng tuần. Nếu worker timeout sau khi gửi nhưng trước khi đánh dấu job xong, cùng job có thể retry và gửi trùng. Đó là bình thường cho mô hình này trừ khi bạn thêm biện pháp bảo vệ (ví dụ, ghi event "email sent" duy nhất theo invoice id).

Những bẫy ít rõ ràng hơn

Tránh trộn scheduling và thực thi trong cùng transaction dài. Nếu bạn giữ transaction mở trong khi gọi network, bạn giữ khóa lâu hơn cần thiết và chặn worker khác.

Chú ý lệch đồng hồ giữa máy. Dùng thời gian database (NOW() trong PostgreSQL) làm nguồn sự thật cho run_atlocked_until, không phải đồng hồ server app.

Đặt thời gian chạy tối đa rõ ràng. Nếu job có thể chạy 30 phút, làm lease dài hơn và gia hạn nếu cần. Nếu không, worker khác có thể lấy giữa chừng.

Giữ bảng job khỏe mạnh. Nếu job đã hoàn thành chất đống mãi, truy vấn chậm và tranh chấp khóa tăng. Chọn quy tắc giữ liệu đơn giản (archive hoặc xóa các hàng cũ) trước khi bảng quá lớn.

Checklist nhanh và bước tiếp theo

Checklist nhanh

Trước khi đưa mô hình này vào dùng, kiểm tra những điều cơ bản. Bỏ quên nhỏ thường biến thành job kẹt, trùng lặp bất ngờ, hoặc worker dội database.

  • Bảng jobs có các trường thiết yếu: run_at, status, attempts, locked_until, và max_attempts (cùng last_error hoặc tương tự để thấy chuyện gì đã xảy ra).
  • Mỗi job có thể chạy hai lần mà không gây hại. Nếu không chắc, thêm idempotency key hoặc ràng buộc duy nhất quanh side effect (ví dụ, một hóa đơn cho invoice_id).
  • Có nơi rõ để quan sát lỗi và quyết định: xem job thất bại, chạy lại một job, hoặc đánh dấu dead khi nên dừng.
  • Thời gian lease hợp lý cho công việc. Nó nên đủ dài cho chạy bình thường, nhưng đủ ngắn để worker crash không chặn tiến trình vài giờ.
  • Retry backoff có thể dự đoán. Nó nên làm chậm các lỗi lặp lại và dừng sau max_attempts.

Nếu các điều này đúng, mô hình cron + database thường ổn cho workloads thực tế.

Bước tiếp theo

Khi checklist ổn, tập trung vào vận hành hằng ngày.

  • Thêm hai hành động quản trị nhỏ: “retry now” (đặt run_at = now() và xóa lock) và “cancel” (chuyển sang trạng thái terminal). Chúng cứu thời gian khi xử lý sự cố.
  • Worker log một dòng mỗi job: job type, job id, attempt number, và kết quả. Thêm alert khi số lỗi tăng.
  • Load test với spike thực tế: nhiều job cho cùng một phút. Nếu việc claim job chậm, thêm index phù hợp (thường là status, run_at).

Nếu bạn muốn dựng nhanh kiểu này, Koder.ai (koder.ai) có thể giúp bạn đi từ schema đến một app Go + PostgreSQL triển khai được với ít công việc nối dây hơn, để bạn tập trung vào khóa, retry, và quy tắc idempotency.

Nếu sau này bạn vượt quá giới hạn của thiết lập này, bạn vẫn hiểu rõ vòng đời job, và các ý tưởng này dễ chuyển sang một hệ thống hàng đợi đầy đủ hơn.

Câu hỏi thường gặp

Khi nào tôi nên dùng cron với cơ sở dữ liệu cho các tác vụ nền?

Hãy dùng mô hình này khi bạn đã có cơ sở dữ liệu, chỉ có một số lượng vừa phải loại tác vụ và muốn chạy công việc theo lịch mà không cần vận hành một hàng đợi riêng. Nó phù hợp với các việc như nhắc nhở qua email, dọn dẹp, báo cáo và làm mới bộ nhớ đệm.

Cron làm gì trong thiết lập này?

Cron chỉ nên khởi động worker theo một khoảng thời gian đều đặn, thường là mỗi phút một lần. Cơ sở dữ liệu quyết định tác vụ nào đến hạn, ghi lại trạng thái của chúng và cho phép một worker nhận từng tác vụ.

Bảng jobs nên bao gồm những trường nào?

Lưu loại tác vụ, một payload JSON nhỏ, thời điểm đến hạn, trạng thái, số lần thử, giới hạn thử lại, chủ sở hữu khóa, thời điểm khóa hết hạn và một thông báo lỗi ngắn. Các trường này cung cấp đủ thông tin để worker chạy và khôi phục tác vụ an toàn.

Nhiều worker tránh chạy cùng một tác vụ bằng cách nào?

Nhận tác vụ trong một giao dịch cơ sở dữ liệu với khóa hàng, chẳng hạn PostgreSQL FOR UPDATE SKIP LOCKED. Cập nhật hàng sang trạng thái đang chạy và đặt lease trước khi worker bắt đầu tác vụ thực tế.

Vì sao tác vụ nền cần thời hạn lease?

Lease là khóa tạm thời được lưu với locked_until. Nếu worker gặp sự cố, lease sẽ hết hạn và worker khác có thể nhận tác vụ thay vì để nó bị kẹt vĩnh viễn.

Các tác vụ thất bại nên thử lại như thế nào?

Thử lại sau với quy tắc backoff rõ ràng, chẳng hạn 1 phút, 2 phút, 4 phút, rồi đến một khoảng chờ có giới hạn kèm chút ngẫu nhiên. Sau số lần thử được phép, hãy đánh dấu tác vụ là dead để nó không tiếp tục tiêu tốn thời gian của worker.

Idempotency là gì và vì sao nó quan trọng?

Hãy bảo đảm hiệu ứng phụ có thể lặp lại an toàn. Ví dụ, dùng khóa idempotency ổn định như ID hóa đơn hoặc ID người dùng cộng với ngày báo cáo, rồi áp dụng tính duy nhất trong PostgreSQL hoặc tại nhà cung cấp bên ngoài.

Mô hình này có bảo đảm xử lý đúng một lần không?

Không. Worker có thể gặp sự cố sau khi gửi email hoặc gọi nhà cung cấp thanh toán nhưng trước khi lưu trạng thái thành công. Hãy thiết kế cho cơ chế xử lý ít nhất một lần, sau đó dùng idempotency để ngăn hiệu ứng trùng lặp.

Worker có nên giữ giao dịch cơ sở dữ liệu trong khi thực hiện tác vụ không?

Giữ các giao dịch cơ sở dữ liệu ngắn. Nhận tác vụ, commit, thực hiện các lệnh gọi mạng bên ngoài giao dịch, rồi lưu thành công hoặc thất bại bằng một lần cập nhật riêng. Giao dịch dài giữ khóa và làm chậm các worker khác.

Làm cách nào để giám sát cron và các tác vụ cơ sở dữ liệu?

Theo dõi các tác vụ dead và các tác vụ đang chạy quá lâu, ghi log từng lần nhận và kết quả, đồng thời cảnh báo khi số lỗi tăng lên. Thêm các thao tác quản trị đơn giản để thử lại một tác vụ ngay, hủy tác vụ hoặc kiểm tra lỗi mới nhất của nó.

Related posts