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 đủ.

sql\n-- What should exist (the definition)\ncreate table job_definitions (\n id bigserial primary key,\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n schedule text, -- optional: cron-like text if you store it\n max_attempts int not null default 5,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\n-- What should run (each run / attempt group)\ncreate table job_runs (\n id bigserial primary key,\n definition_id bigint references job_definitions(id),\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n run_at timestamptz not null,\n status text not null, -- queued | running | succeeded | failed | dead\n attempts int not null default 0,\n max_attempts int not null default 5,\n\n locked_by text,\n locked_until timestamptz,\n\n last_error text,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\n\nMột vài chi tiết giúp đỡ sau này:\n\n- Giữ job_type là một chuỗi ngắn để định tuyến (ví dụ send_invoice_emails).\n- Lưu payload dưới dạng jsonb để dễ mở rộng mà không phải migrate.\n- 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ó.\n- locked_by và locked_until cho phép worker claim job mà không đụng độ nhau.\n- last_error nên ngắn và dễ đọc. Lưu stack trace ở nơi khác nếu cần.\n\n### Index bạn sẽ muốn\n\nKhông có index, worker sẽ phải scan quá nhiều. Bắt đầu với:\n\n- Index để tìm công việc đến hạn nhanh: (status, run_at)\n- Index để giúp phát hiện lock hết hạn: (locked_until)\n- Tùy chọn: index phân mảnh cho công việc active (ví dụ status trong queued và failed)\n\nChúng giữ truy vấn “tìm job tiếp theo chạy được” nhanh ngay cả khi bảng lớn lên.\n\n## Khóa và claim job an toàn\n\nMụ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.\n\nCá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.\n\n### Dùng lease để crash không chặn công việc mãi mãi\n\nNế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.\n\nQuy tắc phổ biến: job có thể được claim khi locked_until là NULL hoặc locked_until <= now().\n\n### Claim job bằng một cập nhật nguyên tử\n\nĐ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.\n\nĐâ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.)\n\nsql\nWITH next_job AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued'\n AND run_at <= now()\n AND (locked_until IS NULL OR locked_until <= now())\n ORDER BY run_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n)\nUPDATE jobs j\nSET status = 'running',\n locked_until = now() + interval '2 minutes',\n locked_by = $1,\n attempts = attempts + 1,\n updated_at = now()\nFROM next_job\nWHERE j.id = next_job.id\nRETURNING j.*;\n\n\nTại sao nó hoạt động:\n\n- FOR UPDATE SKIP LOCKED cho phép nhiều worker cạnh tranh mà không chặn nhau.\n- Lease được đặt khi claim, nên worker khác bỏ qua cho đến khi lease hết hạn.\n- RETURNING đưa hàng cho worker thắng cuộc.\n\n### Thời lượng lease nên là bao lâu, và làm sao gia hạn?\n\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ư.\n\nVớ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.\n\n- Độ dài lease: 5x đến 20x thời gian job điển hình\n- Khoảng heartbeat: 1/4 đến 1/2 lease\n- Cập nhật gia hạn nên có WHERE id = $job_id AND locked_by = $worker_id\n\nĐ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.\n\n## Thử lại và backoff hoạt động có thể dự đoán\n\nThử 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.\n\nBắ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.\n\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:\n\n- tăng attempts\n- đặt trạng thái thành failed nếu attempts < max_attempts, nếu không thì dead\n- tính run_at cho lần thử tiếp theo (chỉ cho failed)\n\nBackoff 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:\n\n- Delay cố định: luôn chờ 1 phút\n- Lũy thừa: 1m, 2m, 4m, 8m\n- Lũy thừa với giới hạn: lũy thừa nhưng không quá, ví dụ 30m\n- 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\n\nJitter 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.\n\nLư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:\n\n- last_error (thông điệp ngắn, an toàn để hiển thị trong admin)\n- error_code hoặc error_type (giúp nhóm lỗi)\n- failed_at và next_run_at\n- tùy chọn last_stack (chỉ nếu bạn quản lý kích thước)\n\nQuy 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.\n\n## Idempotency: ngăn trùng lặp ngay cả khi job lặp lại\n\nIdempotency 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.\n\nCá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.\n\n### Dùng idempotency key gắn với sự kiện nghiệp vụ\n\nIdempotency 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.\n\nVí 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.\n\n### Áp dụng "chỉ một lần" bằng ràng buộc database\n\nBiệ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.\n\nsql\n-- Example: ensure one logical job/effect per business key\nALTER TABLE jobs\nADD COLUMN idempotency_key text;\n\nCREATE UNIQUE INDEX jobs_idempotency_key_uniq\nON jobs (idempotency_key)\nWHERE idempotency_key IS NOT NULL;\n\n\nĐ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).\n\nCác side effect phổ biến cần bảo vệ:\n\n- 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.\n- Webhook: lưu delivered_webhooks(event_id) và bỏ qua nếu đã tồn tại.\n- 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.\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\nNế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.\n\n## Bước từng bước: xây worker và scheduler tối thiểu\n\nChọ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à đủ.\n\n### Xây trong năm bước nhỏ\n\n1) 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)).\n\n2) 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).\n\nsql\nINSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)\nVALUES ($1, $2::jsonb, 'queued', $3, 0, 10);\n\n\n3) 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.\n\nsql\nWITH picked AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued' AND run_at <= now()\n ORDER BY run_at\n FOR UPDATE SKIP LOCKED\n LIMIT 10\n)\nUPDATE jobs\nSET status = 'running', started_at = now()\nWHERE id IN (SELECT id FROM picked)\nRETURNING *;\n\n\n4) 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.\n\n5) 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'.\n\n### Thêm khả năng quan sát cơ bản\n\nBạn không cần dashboard đầy đủ ngày đầu, nhưng cần đủ để nhận ra vấn đề.\n\n- Log một dòng cho mỗi job: claimed, succeeded, failed, retried, dead.\n- Tạo query hoặc view đơn cho “dead jobs” và “running lâu”.\n- Alert khi số lượng lớn (ví dụ hơn N dead jobs trong giờ vừa qua).\n\nNế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.\n\n## Một ví dụ thực tế để bạn sao chép\n\nTưởng tượng một app SaaS nhỏ với hai việc theo lịch:\n\n- Dọn dẹp nightly xóa session hết hạn và file tạm.\n- 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.\n\nGiữ đơ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.\n\n### Cái gì được enqueue, và khi nào\n\nBạn có thể enqueue jobs từ vài nơi:\n\n- Hàng ngày lúc 02:00: enqueue một job cleanup_nightly cho “hôm nay”.\n- Khi signup: enqueue một job send_weekly_report cho Monday tiếp theo của user.\n- 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ể.\n\nPayload chỉ là tối thiểu worker cần. Giữ nhỏ để dễ retry.\n\njson\n{\n "type": "send_weekly_report",\n "payload": {\n "user_id": 12345,\n "date_range": {\n "from": "2026-01-01",\n "to": "2026-01-07"\n }\n }\n}\n\n\n### Idempotency ngăn gửi đôi như thế nào\n\nWorker 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.\n\nĐể 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.\n\nViệ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.\n\n### Một lỗi trông như thế nào (và cách phục hồi)\n\nVí dụ nhà cung cấp email timeout. Job fail, nên worker:\n\n- tăng attempts\n- lưu thông điệp lỗi để debug\n- lên lịch thử lại theo backoff (ví dụ: +1 min, +5 min, +30 min, +2 hours)\n\nNế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.\n\n## Sai lầm phổ biến và bẫy\n\nMô 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.\n\n### Sai lầm gây trùng lặp hoặc job kẹt\n\nHầu hết sự cố thực tế đến từ vài bẫy sau:\n\n- 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.\n- 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.\n- 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.\n- 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.\n- 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.\n\nVí 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).\n\n### Những bẫy ít rõ ràng hơn\n\nTrá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.\n\nChú ý 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_at và locked_until, không phải đồng hồ server app.\n\nĐặ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.\n\nGiữ 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.\n\n## Checklist nhanh và bước tiếp theo\n\n### Checklist nhanh\n\nTrướ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.\n\n- 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).\n- 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).\n- 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.\n- 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ờ.\n- 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\nNếu các điều này đúng, mô hình cron + database thường ổn cho workloads thực tế.\n\n### Bước tiếp theo\n\nKhi checklist ổn, tập trung vào vận hành hằng ngày.\n\n- 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ố.\n- 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.\n- 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\nNế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\nNế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.