Lên lịch công việc nền mà không đau đầu vì cron: các mẫu
Tìm hiểu các mẫu thiết kế để lập lịch công việc nền bằng luồng công việc và bảng jobs, giúp gửi nhắc nhở, báo cáo hàng ngày và dọn dẹp một cách đáng tin cậy.

Tại sao cron có vẻ đơn giản cho tới khi không còn đơn giản nữa
Cron thật tuyệt vào ngày đầu: viết một dòng, chọn thời điểm, rồi quên. Với một server và một tác vụ, thường là nó hoạt động.
Vấn đề lộ ra khi bạn dựa vào lịch để điều chỉnh hành vi sản phẩm thực sự: nhắc nhở, báo cáo hàng ngày, dọn dẹp hoặc đồng bộ. Hầu hết câu chuyện "chạy bị bỏ lỡ" không phải do cron hỏng. Là những thứ xung quanh nó: server khởi động lại, deploy ghi đè crontab, một job chạy lâu hơn dự kiến, hoặc lỗi đồng hồ hoặc múi giờ. Và khi bạn chạy nhiều instance app, bạn có thể gặp chế độ lỗi ngược lại: trùng lặp, vì hai máy đều nghĩ mình phải chạy cùng một tác vụ.
Kiểm thử cũng là điểm yếu. Một dòng cron không cho bạn cách rõ ràng để chạy “chuyện gì sẽ xảy ra lúc 9:00 AM ngày mai” trong một bài kiểm thử lặp lại. Vì vậy lên lịch biến thành kiểm tra thủ công, bất ngờ ở production và săn log.
Trước khi chọn cách tiếp cận, hãy rõ ràng về cái bạn đang lập lịch. Hầu hết công việc nền rơi vào vài nhóm:
- Nhắc nhở (gửi vào thời điểm cụ thể, chỉ một lần)
- Báo cáo hàng ngày (tổng hợp dữ liệu rồi gửi)
- Tác vụ dọn dẹp (xóa, lưu trữ, hết hạn)
- Đồng bộ định kỳ (kéo hoặc đẩy cập nhật)
Đôi khi bạn có thể bỏ qua việc lập lịch hoàn toàn. Nếu điều gì đó có thể xảy ra ngay khi một sự kiện xảy ra (người dùng đăng ký, thanh toán thành công, ticket thay đổi trạng thái), công việc dựa trên sự kiện thường đơn giản và đáng tin cậy hơn công việc theo thời gian.
Khi bạn cần thời gian, độ tin cậy chủ yếu phụ thuộc vào khả năng quan sát và kiểm soát. Bạn muốn có nơi ghi lại cái gì nên chạy, cái gì đã chạy, và cái gì đã thất bại, cùng cách an toàn để thử lại mà không tạo trùng lặp.
Mẫu cơ bản: scheduler, bảng jobs, worker
Một cách đơn giản để tránh rắc rối với cron là tách trách nhiệm:
- Một scheduler quyết định cái gì nên chạy và khi nào.
- Một worker thực hiện công việc.
Tách hai vai trò giúp theo hai hướng. Bạn có thể đổi thời gian mà không đụng tới logic nghiệp vụ, và đổi logic nghiệp vụ mà không phá lịch.
Một bảng jobs trở thành nguồn chân lý. Thay vì giấu trạng thái trong tiến trình server hoặc trong một dòng cron, mỗi đơn vị công việc là một hàng: làm gì, cho ai, khi nào nên chạy, và lần trước xảy ra gì. Khi có sự cố, bạn có thể kiểm tra, thử lại hoặc huỷ mà không phải đoán mò.
Một luồng điển hình như sau:
- Scheduler quét các job đến hạn (ví dụ
run_at <= nowvàstatus = queued). - Nó chiếm một job để chỉ có một worker lấy nó.
- Worker đọc chi tiết job và thực hiện hành động.
- Worker ghi lại kết quả trở lại cùng hàng đó.
Ý tưởng then chốt là làm cho công việc có thể tiếp tục, không phải huyền bí. Nếu một worker crash giữa chừng, hàng job vẫn nên cho bạn biết chuyện gì đã xảy ra và bước tiếp theo là gì.
Thiết kế bảng jobs để hữu dụng lâu dài
Một bảng jobs nên trả lời nhanh hai câu: cái gì cần chạy tiếp theo, và lần trước xảy ra gì.
Bắt đầu với tập trường nhỏ che phủ định danh, thời gian và tiến độ:
- id, type: id duy nhất cộng với một loại ngắn như
send_reminderhoặcdaily_summary. - payload: JSON đã được validate chỉ gồm những gì worker cần (ví dụ
user_id, chứ không phải toàn bộ đối tượng user). - run_at: khi job đủ điều kiện để chạy.
- status:
queued,running,succeeded,failed,canceled. - attempts: tăng lên mỗi lần thử.
Rồi thêm vài cột vận hành giúp an toàn khi chạy đồng thời và xử lý sự cố. locked_at, locked_by, và locked_until cho phép một worker chiếm job để bạn không chạy nó hai lần. last_error nên là một thông điệp ngắn (và có thể kèm mã lỗi), không phải một dump stack trace đầy đủ làm phình to hàng.
Cuối cùng, giữ các timestamp hữu ích cho hỗ trợ và báo cáo: created_at, updated_at, và finished_at. Chúng giúp trả lời câu như “Hôm nay có bao nhiêu nhắc nhở thất bại?” mà không cần mò log.
Indexes quan trọng vì hệ thống của bạn liên tục hỏi “cái gì tiếp theo?” Hai index thường đáng giá:
(status, run_at)để lấy job đến hạn nhanh(type, status)để kiểm tra hoặc tạm dừng một nhóm job khi có vấn đề
Với payload, ưu tiên JSON nhỏ, tập trung và validate trước khi chèn job. Lưu các định danh và tham số, không lưu snapshot dữ liệu nghiệp vụ. Xử lý cấu trúc payload như hợp đồng API để job cũ trong hàng vẫn chạy được sau khi bạn thay đổi app.
Vòng đời job: trạng thái, khóa và idempotency
Một trình chạy job đáng tin khi mỗi job theo một vòng đời nhỏ, dễ dự đoán. Vòng đời này là mạng lưới an toàn khi hai worker bắt đầu cùng lúc, server khởi động lại giữa chừng, hoặc bạn cần thử lại mà không tạo trùng.
Một state machine đơn giản thường đủ:
- queued: sẵn sàng chạy vào hoặc sau
run_at - running: đã bị worker chiếm
- succeeded: hoàn thành và không nên chạy lại
- failed: kết thúc với lỗi và cần chú ý
- canceled: dừng có chủ ý (ví dụ user hủy)
Chiếm job mà không làm đôi
Để tránh trùng, chiếm job cần là thao tác nguyên tử. Cách phổ biến là khoá có timeout (một lease): worker chiếm job bằng cách set status=running và ghi locked_by cùng locked_until. Nếu worker crash, lease hết hạn và worker khác có thể chiếm lại.
Một bộ quy tắc chiếm thực tế:
- chiếm chỉ các job
queuedmàrun_at <= now - set
status,locked_by, vàlocked_untiltrong cùng một update - chiếm lại job
runningchỉ khilocked_until < now - giữ lease ngắn và gia hạn nếu job chạy lâu
Idempotency (thói quen cứu bạn)
Idempotency nghĩa là: nếu cùng một job chạy hai lần, kết quả vẫn đúng.
Công cụ đơn giản nhất là khoá duy nhất. Ví dụ, với báo cáo hàng ngày bạn có thể đảm bảo một job cho mỗi user mỗi ngày bằng một key như summary:user123:2026-01-25. Nếu chèn trùng xảy ra, nó trỏ tới cùng một job thay vì tạo job thứ hai.
Chỉ đánh dấu thành công khi tác động thực sự hoàn tất (email đã gửi, bản ghi đã cập nhật). Nếu bạn thử lại, đường thử lại không được tạo email thứ hai hay ghi trùng.
Thử lại và xử lý lỗi không ầm ĩ
Thử lại là nơi hệ thống job hoặc trở nên đáng tin hoặc biến thành tiếng ồn. Mục tiêu đơn giản: thử lại khi lỗi có khả năng tạm thời, dừng khi không phải vậy.
Chính sách thử lại mặc định thường gồm:
- số lần thử tối đa (ví dụ 5 lần)
- chiến lược trì hoãn (delay cố định hoặc exponential backoff)
- điều kiện dừng (không thử lại lỗi kiểu “dữ liệu không hợp lệ”)
- jitter (một chút ngẫu nhiên để tránh đồng loạt thử lại)
Thay vì sáng tạo trạng thái mới cho thử lại, bạn có thể tái dùng queued: đặt run_at sang thời điểm thử tiếp theo và đưa job trở lại hàng. Điều đó giữ state machine nhỏ.
Khi một job có thể tiến triển từng phần, coi đó là bình thường. Lưu một checkpoint để retry có thể tiếp tục an toàn, hoặc trong payload của job (như last_processed_id) hoặc trong một bảng liên quan.
Ví dụ: một job báo cáo hàng ngày tạo tin cho 500 user. Nếu nó thất bại ở user thứ 320, lưu id user thành công cuối cùng và thử lại từ 321. Nếu bạn cũng lưu một bản ghi summary_sent cho mỗi user mỗi ngày, lần chạy lại có thể bỏ qua những user đã xong.
Logging hữu ích
Ghi log vừa đủ để debug trong vài phút:
- job id, type và số lần thử
- các input chính (user/team id, phạm vi ngày)
- thời gian (started_at, finished_at, next run time)
- tóm tắt lỗi ngắn (và stack trace nếu bạn muốn)
- số lượng tác động (email đã gửi, dòng đã cập nhật)
Từng bước: xây một vòng lặp scheduler đơn giản
Một vòng lặp scheduler là một tiến trình nhỏ thức dậy theo nhịp cố định, tìm công việc đến hạn và giao cho worker. Mục tiêu là độ tin cậy tẻ nhạt, không phải thời gian hoàn hảo. Với nhiều app, “thức dậy mỗi phút” là đủ.
Chọn tần suất dựa trên mức độ nhạy thời gian của job và tải cơ sở dữ liệu. Nếu nhắc nhở cần gần real-time, chạy mỗi 30–60 giây. Nếu báo cáo hàng ngày có thể trễ vài phút, mỗi 5 phút là đủ và rẻ hơn.
Vòng lặp đơn giản:
- Thức dậy và lấy thời gian hiện tại (dùng UTC).
- Chọn job đến hạn nơi
status = 'queued'vàrun_at <= now. - Chiếm job an toàn để chỉ có một worker lấy chúng.
- Giao mỗi job đã chiếm cho worker.
- Ngủ tới tick tiếp theo.
Bước chiếm là nơi nhiều hệ thống hỏng. Bạn muốn đánh dấu job running (và lưu locked_by và locked_until) trong cùng transaction với việc chọn nó. Nhiều cơ sở dữ liệu hỗ trợ đọc “skip locked” để nhiều scheduler chạy cùng nhau mà không chồng chéo.
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
Giữ kích thước batch nhỏ (như 50–200). Batch lớn làm chậm DB và khiến crash đau hơn.
Nếu scheduler crash giữa chừng, lease cứu bạn. Job kẹt ở running sẽ đủ điều kiện lại sau locked_until. Worker của bạn nên idempotent để job bị chiếm lại không tạo email trùng hay charge đôi.
Mẫu cho nhắc nhở, báo cáo hàng ngày và dọn dẹp
Hầu hết team cuối cùng có ba loại công việc nền giống nhau: thông điệp cần gửi đúng lúc, báo cáo định kỳ và dọn dẹp giữ hiệu năng. Cùng một bảng jobs và vòng worker có thể xử lý tất cả.
Nhắc nhở
Với nhắc nhở, lưu mọi thứ cần để gửi thông điệp trong hàng job: đối tượng nhận, kênh (email, SMS, Telegram, in-app), template và thời điểm gửi chính xác. Worker nên chạy job mà không cần “tìm thêm” context.
Nếu nhiều nhắc nhở đến cùng lúc, thêm giới hạn tốc độ. Giới hạn số tin/phút cho từng kênh và để các job thừa chờ đến lần chạy sau.
Báo cáo hàng ngày
Báo cáo hàng ngày thường hỏng khi cửa sổ thời gian mơ hồ. Chọn một thời điểm cắt rõ ràng (ví dụ 08:00 theo giờ địa phương user) và định nghĩa cửa sổ rõ ràng (ví dụ “hôm qua 08:00 đến hôm nay 08:00”). Lưu thời điểm cắt và timezone người dùng trong job để chạy lại cho kết quả giống nhau.
Giữ mỗi job báo cáo nhỏ. Nếu cần xử lý hàng nghìn bản ghi, chia thành các đoạn (theo team, theo account, hoặc theo range id) và enqueue các job phụ.
Dọn dẹp
Dọn dẹp an toàn hơn khi bạn tách “xóa” khỏi “lưu trữ.” Quyết định cái gì có thể xóa vĩnh viễn (token tạm thời, sessions hết hạn) và cái gì nên lưu trữ (log audit, hoá đơn). Chạy dọn dẹp theo batch cố định để tránh khoá lâu và spike tải.
Thời gian và múi giờ: nguồn lỗi ẩn
Nhiều lỗi là lỗi thời gian: nhắc nhở gửi sớm một tiếng, báo cáo hàng ngày bỏ qua thứ Hai, hoặc dọn dẹp chạy hai lần.
Mặc định tốt là lưu timestamp lập lịch bằng UTC và lưu múi giờ người dùng riêng. run_at nên là một thời điểm UTC duy nhất. Khi user nói “9:00 AM giờ tôi,” chuyển sang UTC khi lên lịch.
DST là nơi các thiết lập ngây thơ vỡ. “Mỗi ngày lúc 9:00 AM” không bằng “mỗi 24 giờ.” Khi chuyển DST, 9:00 AM map tới một thời điểm UTC khác, và một số local time không tồn tại (spring forward) hoặc xuất hiện hai lần (fall back). Cách an toàn là tính lần xảy ra local tiếp theo mỗi lần bạn lập lịch lại, rồi chuyển nó sang UTC.
Với báo cáo hàng ngày, quyết định “một ngày” nghĩa là gì trước khi code. Một ngày theo lịch (0:00–24:00 theo múi giờ user) phù hợp với kỳ vọng con người. “24 giờ gần nhất” đơn giản hơn nhưng sẽ trôi và gây bất ngờ.
Dữ liệu đến muộn là không tránh khỏi: một event tới sau khi retry, hoặc một ghi chú thêm vài phút sau nửa đêm. Quyết định xem event muộn thuộc “hôm qua” (với khoảng đệm) hay “hôm nay,” và giữ quy tắc nhất quán.
Một buffer thực tế có thể tránh bỏ sót:
- quét các job đến hạn trong vòng 2–5 phút trước
- làm job idempotent để chạy lại an toàn
- ghi phạm vi thời gian đã xử lý trong payload để báo cáo nhất quán
Sai lầm phổ biến gây bỏ lỡ hoặc trùng chạy
Hầu hết đau đầu đến từ vài giả định. Lớn nhất là giả định “chỉ chạy đúng một lần.” Trong hệ thống thực, worker khởi động lại, network timeout, và khóa có thể mất. Thông thường bạn được “ít nhất một lần” (at least once), nghĩa là trùng chạy là bình thường và code của bạn phải chịu được.
Một lỗi khác là thực hiện tác dụng trước (gửi email, charge thẻ) mà không kiểm tra dedupe. Một rào cản đơn giản thường giải quyết được: một sent_at timestamp, một key duy nhất như (user_id, reminder_type, date), hoặc token dedupe đã lưu.
Khả năng quan sát là khoảng trống tiếp theo. Nếu bạn không thể trả lời “cái gì đang kẹt, từ khi nào, và vì sao,” bạn sẽ phỏng đoán. Dữ liệu tối thiểu cần giữ gần là status, attempt count, next scheduled time, last error và worker id.
Những sai lầm hay gặp nhất:
- thiết kế job như thể nó chạy đúng một lần, rồi ngạc nhiên khi có trùng
- thực hiện side effects mà không có kiểm tra dedupe
- một job khổng lồ cố làm tất cả và bị timeout giữa chừng
- retry vô hạn không có giới hạn
- thiếu visibility cơ bản của queue (không có nhìn tổng quan backlog, failures, item chạy lâu)
Ví dụ cụ thể: job báo cáo hàng ngày lặp qua 50,000 user và timeout ở user 20,000. Lần retry nó bắt đầu lại và gửi báo cáo lần hai cho 20,000 user đầu nếu bạn không theo dõi hoàn thành từng user hoặc chia job thành job cho mỗi user.
Checklist nhanh cho hệ thống job đáng tin
Một trình chạy job chỉ “xong” khi bạn tin tưởng nó lúc 2 giờ sáng.
Đảm bảo bạn có:
- Queue visibility: số lượng queued vs running vs failed, cùng job queued lâu nhất.
- Idempotency theo mặc định: giả sử mỗi job có thể chạy hai lần; dùng key duy nhất hoặc dấu “đã xử lý”.
- Chính sách retry theo loại job: số lần retry, backoff, và điều kiện dừng rõ.
- Lưu thời gian nhất quán: giữ
run_atbằng UTC; chỉ chuyển đổi khi nhập và khi hiển thị. - Khóa có thể phục hồi: một lease để crash không để job chạy mãi.
Cũng giới hạn batch size (bao nhiêu job chiếm một lần) và độ đồng thời worker (bao nhiêu job chạy cùng lúc). Không giới hạn, một spike có thể quá tải DB hoặc làm đói các công việc khác.
Ví dụ thực tế: nhắc nhở và báo cáo cho team nhỏ
Một công cụ SaaS nhỏ có 30 account khách hàng. Mỗi account muốn hai thứ: nhắc nhở 9:00 AM cho task còn mở, và báo cáo 6:00 PM hàng ngày về những gì thay đổi hôm đó. Họ cũng cần dọn dẹp hàng tuần để DB không đầy log cũ và token hết hạn.
Họ dùng bảng jobs cộng worker polling các job đến hạn. Khi khách hàng mới đăng ký, backend lên lịch chạy nhắc nhở và báo cáo đầu tiên dựa trên múi giờ của khách.
Job được tạo ở vài thời điểm phổ biến: khi signup (tạo lịch định kỳ), khi có một sự kiện cụ thể (enqueue thông báo một lần), theo tick lịch (chèn các lần chạy sắp tới), và vào ngày bảo trì (enqueue dọn dẹp).
Một thứ Ba, nhà cung cấp email có outage tạm thời lúc 8:59 AM. Worker cố gửi nhắc nhở, bị timeout, và lên lịch lại các job đó bằng backoff (ví dụ 2 phút, rồi 10, rồi 30), tăng attempts mỗi lần. Vì mỗi job nhắc nhở có khoá idempotency như account_id + date + job_type, retry không tạo trùng nếu provider phục hồi giữa chừng.
Dọn dẹp chạy hàng tuần theo batch nhỏ để không chặn công việc khác. Thay vì xóa một triệu dòng trong một job, nó xóa tối đa N dòng mỗi lần và tự lên lịch lại cho tới khi hoàn tất.
Khi một khách hàng than “Tôi không nhận báo cáo,” team kiểm tra bảng jobs cho account và ngày đó: trạng thái job, số lần thử, các trường khóa hiện tại, và lỗi cuối cùng trả về từ provider. Điều đó biến “nó đáng lẽ đã gửi” thành “đây chính xác là điều đã xảy ra.”
Bước tiếp theo: triển khai, quan sát, rồi mở rộng
Chọn một loại job và xây nó end-to-end trước khi thêm nhiều loại khác. Một job nhắc nhở đơn là khởi đầu tốt vì nó chạm tới mọi thứ: lên lịch, chiếm job, gửi message và ghi kết quả.
Bắt đầu với phiên bản bạn có thể tin tưởng:
- tạo bảng jobs và một worker xử lý một loại job
- thêm một vòng lặp scheduler chiếm và chạy job đến hạn
- lưu đủ payload để chạy job mà không phải đoán thêm
- log mọi lần thử và kết quả để câu hỏi “Nó có chạy không?” trả lời trong 10 giây
- thêm đường chạy thủ công để chạy lại job thất bại mà không cần deploy
Khi chạy ổn, làm cho hệ thống quan sát được cho người. Một view admin cơ bản nhanh chóng mang lại giá trị: tìm job theo trạng thái, lọc theo thời gian, kiểm tra payload, huỷ job kẹt, chạy lại job theo id.
Nếu bạn thích xây luồng scheduler và worker bằng logic backend trực quan, AppMaster (appmaster.io) có thể mô hình hóa bảng jobs trong PostgreSQL và triển khai vòng claim-process-update dưới dạng Business Process, đồng thời sinh mã nguồn thực tế để deploy.


