Cron, Queues và việc chạy nền
Việc định kỳ đúng giờ Việt Nam, hàng đợi cho việc chậm, và luật cứng: không bao giờ nhét việc nặng vào waitUntil.
App thật luôn có việc không nằm trong một lượt bấm: gửi báo cáo mỗi sáng, dọn dữ liệu cũ, đẩy dữ liệu sang hệ thống khác, gửi hàng loạt thông báo. Bài này nói chỗ đặt những việc đó — và chỗ tuyệt đối không đặt.
Luật cứng trước đã
Không bao giờ nhét việc nặng hoặc nhiều lượt gọi cơ sở dữ liệu vào
ctx.waitUntil.
waitUntil chạy sau khi người dùng đã nhận kết quả, nên trông như miễn phí. Thực tế:
- Nó có hạn thời gian. Quá hạn, Cloudflare hủy: "waitUntil() tasks did not complete within the allowed time and have been cancelled" — để lại việc làm dở.
- Nó giữ D1 dùng chung. D1 xử lý tuần tự cả cơ sở dữ liệu, nên trong lúc việc nền chạy, mọi request khác xếp hàng. App treo, mà nhìn log thì không đường nào có vẻ chậm.
Sự cố thật: một tác vụ dọn dẹp 40 lượt xóa ảnh base64 nhét trong waitUntil làm sập đường tải của cả app suốt một buổi chiều.
waitUntil chỉ dùng cho việc nhẹ, một lượt, mất cũng không sao: ghi một dòng log, bắn một thông báo.
Cron Triggers — việc định kỳ
# wrangler.toml
[triggers]
crons = [
"0 1 * * *", # 01:00 UTC = 08:00 gio Viet Nam — bao cao sang
"*/15 * * * *", # moi 15 phut — dong bo
"0 17 * * 0" # 00:00 thu Hai gio VN — don dep hang tuan
]
export default {
async fetch(request, env, ctx) { /* ... */ },
async scheduled(event, env, ctx) {
switch (event.cron) {
case "0 1 * * *": return baoCaoSang(env);
case "*/15 * * * *": return dongBo(env);
case "0 17 * * 0": return donDepTuan(env);
}
},
};
Muốn chạy lúc 8 giờ sáng giờ Việt Nam thì viết 0 1 * * *. Nhầm chỗ này là báo cáo hằng ngày gửi lúc 3 giờ chiều, và bạn sẽ mất một ngày mới nhận ra.
Bảng quy đổi nhanh:
| Giờ Việt Nam | Cron (UTC) |
|---|---|
| 06:00 | 0 23 * * * (hôm trước) |
| 08:00 | 0 1 * * * |
| 12:00 | 0 5 * * * |
| 17:30 | 30 10 * * * |
| 00:00 | 0 17 * * * (hôm trước) |
Nhớ cả chuyện ngày trong tuần cũng lệch: "0 giờ thứ Hai giờ Việt Nam" là 0 17 * * 0 — tức Chủ nhật theo UTC.
Chạy thử tại máy, không cần chờ tới giờ:
npx wrangler dev --test-scheduled
# cửa sổ khác:
curl "http://localhost:8787/__scheduled?cron=0+1+*+*+*"
Cron của Cloudflare đúng giờ hơn hẳn GitHub Actions — Actions có thể trễ 5–30 phút hoặc bỏ lượt. Việc không được phép trượt (chốt sổ, gửi báo cáo cho sếp) thì dùng Cron Trigger.
Viết việc định kỳ cho đúng
Bốn ràng buộc, thiếu cái nào cũng đau:
async function dongBo(env) {
// 1. KHÓA — tránh hai lượt chồng nhau khi lượt trước chạy quá lâu
const dangChay = await env.CAU_HINH.get("dong-bo:dang-chay");
if (dangChay && Date.now() - +dangChay < 10 * 60 * 1000) {
console.log("Bo qua: luot truoc dang chay");
return;
}
await env.CAU_HINH.put("dong-bo:dang-chay", String(Date.now()), { expirationTtl: 900 });
try {
// 2. TỪNG MẺ — không bao giờ xử lý cả bảng một lần
const { results } = await env.DB.prepare(
"SELECT id, du_lieu FROM hang_doi WHERE trang_thai = 'cho' ORDER BY id LIMIT 50"
).all();
for (const r of results) {
try {
await guiDiNoiKhac(r.du_lieu);
// 3. GHI KẾT QUẢ NGAY từng dòng, không đợi hết vòng lặp
await env.DB.prepare("UPDATE hang_doi SET trang_thai='xong' WHERE id=?").bind(r.id).run();
} catch (e) {
await env.DB.prepare(
"UPDATE hang_doi SET so_lan_loi = so_lan_loi + 1, loi_cuoi = ? WHERE id = ?"
).bind(String(e.message).slice(0, 200), r.id).run();
}
}
} finally {
// 4. LUÔN mở khóa, kể cả khi lỗi
await env.CAU_HINH.delete("dong-bo:dang-chay");
}
}
Mốc khóa phải lưu ở KV hoặc D1, không phải biến trong bộ nhớ. Lý do đã nói ở Workers: Cloudflare chạy nhiều bản mã của bạn song song, mỗi bản có biến riêng, nên let dangChay = false ở tầng module không khóa được gì cả — và lúc tải cao nó còn làm việc định kỳ chạy dày hơn hàng chục lần.
Hàng đợi tự làm bằng D1 — đủ cho hầu hết app
Trước khi dùng Queues, hãy biết rằng một bảng D1 + một cron mỗi phút giải quyết được phần lớn nhu cầu, và nó miễn phí.
CREATE TABLE hang_doi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
loai TEXT NOT NULL, -- 'zalo' | 'email' | 'sheet'
du_lieu TEXT NOT NULL, -- JSON
trang_thai TEXT NOT NULL DEFAULT 'cho',-- cho | dang | xong | loi
so_lan_loi INTEGER NOT NULL DEFAULT 0,
loi_cuoi TEXT,
gui_sau TEXT, -- mốc giờ sớm nhất được gửi
tao_luc TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_hd_cho ON hang_doi(trang_thai, gui_sau);
Mẫu này đang chạy thật cho một app đẩy dữ liệu sang Google Sheets: người dùng bấm xong là xong ngay (chỉ ghi một dòng vào hàng đợi), cron mỗi phút đẩy đi. Hai cái lợi lớn:
- Người dùng không phải chờ dịch vụ bên ngoài.
- Dịch vụ bên ngoài chết thì không mất dữ liệu — nó nằm trong hàng đợi, chạy lại được.
Thêm cột gui_sau cho phép hẹn giờ gửi (ví dụ chỉ gửi thông báo Zalo trong giờ hành chính) — một yêu cầu rất hay gặp mà khách luôn thích.
Queues của Cloudflare — khi nào đáng dùng
Dùng khi: lượng việc lớn (hàng nghìn mỗi phút), cần thử lại tự động, cần hàng đợi chết (dead letter) cho việc hỏng.
[[queues.producers]]
queue = "viec-nen"
binding = "HANG_DOI"
[[queues.consumers]]
queue = "viec-nen"
max_batch_size = 10
max_retries = 3
dead_letter_queue = "viec-hong"
// đẩy việc vào
await env.HANG_DOI.send({ loai: "gui-email", donId: 42 });
// xử lý
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
try { await xuLy(msg.body, env); msg.ack(); }
catch (e) { msg.retry(); } // tự thử lại, quá số lần thì sang hàng đợi chết
}
},
};
Queues không có trong gói miễn phí. Với app nội bộ vài trăm người, hàng đợi D1 ở trên là đủ và rẻ hơn.
Lỗi trong một lượt bấm thì người dùng báo ngay. Lỗi trong việc nền thì im lặng hàng tuần — không ai ngồi nhìn cron chạy.
Một bản vá SQL hỏng nằm trong waitUntil từng chạy sai suốt nhiều ngày mà không ai hay, vì lỗi bị nuốt và không có gì báo.
Ba việc phải làm cho mọi việc nền, không có ngoại lệ:
- Ghi lại mỗi lượt chạy — lúc nào, xử lý bao nhiêu dòng, lỗi mấy dòng. Một bảng
nhat_ky_cron5 cột là đủ. - Hiện lên màn hình quản trị. Một dòng "Đồng bộ lần cuối: 08:15 hôm nay · 42 dòng · 0 lỗi". Nhìn phát biết ngay.
- Báo động khi im lặng. Không chạy quá 2 chu kỳ thì phải có gì đó kêu lên. Việc nền chết âm thầm nguy hiểm hơn việc nền báo lỗi.
Cron có thể chạy lại, mạng có thể đứt giữa chừng, bạn có thể bấm nút chạy tay trong lúc cron đang chạy. Nếu việc nền của bạn là "gửi thông báo", chạy hai lần là khách nhận hai tin nhắn; nếu là "cộng doanh thu", chạy hai lần là sổ sai gấp đôi.
Cách chống:
- Đánh dấu từng dòng đã xử lý (trang_thai='xong') thay vì đánh dấu cả mẻ.
- Dùng khóa chống trùng ở cơ sở dữ liệu (UNIQUE trên mã việc).
- Việc cộng dồn thì tính lại từ dữ liệu gốc, đừng cộng thêm vào kết quả cũ.
Thêm hàng đợi gửi thông báo Zalo, theo mẫu D1 + cron:
- Bảng hang_doi: loai, du_lieu, trang_thai, so_lan_loi, loi_cuoi,
gui_sau, tao_luc. Index theo (trang_thai, gui_sau).
- Khi người dùng thao tác: CHỈ ghi một dòng vào hang_doi rồi trả
kết quả ngay. Không gọi Zalo trong request nóng.
- Cron mỗi phút: lấy tối đa 20 dòng 'cho' có gui_sau <= bây giờ,
gửi từng dòng, ghi kết quả NGAY từng dòng.
- Khóa chống chồng lượt lưu ở KV (không dùng biến module), tự mở
khóa trong finally.
- Lỗi quá 5 lần thì chuyển trang_thai='loi' và thôi thử.
- Chỉ gửi trong khung giờ cấu hình được (mặc định 07:30–20:00 giờ
Việt Nam) — ngoài giờ thì đặt gui_sau sang mốc mở cửa tiếp theo.
- Ghi bảng nhat_ky_cron mỗi lượt chạy: lúc nào, bao nhiêu dòng,
bao nhiêu lỗi.
- Thêm vào trang quản trị: số dòng đang chờ, số dòng lỗi, lần chạy
cuối, và nút "Chạy ngay".
Nhớ: cron tính theo UTC, Việt Nam là UTC+7.
Nút "Chạy ngay" là thứ bạn sẽ cảm ơn chính mình: khi có sự cố, không ai muốn ngồi chờ tới phút tiếp theo.
App của tôi gửi tin nhắn Zalo cho khách khi có đơn mới. Ban đầu tôi gọi thẳng Zalo ngay lúc bấm Lưu. Nhân viên kêu "bấm lưu đơn lâu lắm" — hóa ra mỗi lần lưu phải chờ Zalo trả lời 3–4 giây.
Chuyển sang hàng đợi: bấm Lưu xong ngay lập tức, tin nhắn đi sau vài giây. Không mất tính năng nào, chỉ đổi thứ tự.
Cách tôi nhớ: việc nào khách hàng không ngồi chờ kết quả thì đừng bắt nhân viên ngồi chờ.
- Đủ: luật cấm
waitUntil, cron theo giờ Việt Nam, 4 ràng buộc viết việc định kỳ, hàng đợi D1 tự làm, Queues, yêu cầu quan sát được, chống chạy hai lần. - Đã trả giá thật:
waitUntilgiữ D1 gây treo app; khóa bằng biến module không có tác dụng; lỗi SQL bị nuốt trong việc nền. - Rủi ro còn lại: mọi thứ ở đây giả định việc nền chạy được trong giới hạn thời gian của Workers. Việc thật sự dài (xử lý 50.000 dòng) phải chia thành nhiều lượt cron nối tiếp, mỗi lượt lưu vị trí đang làm dở.
- Dựng hàng đợi theo đề trên cho một việc thật trong app của bạn.
- Thử nghiệm đúng giờ: đặt cron
*/5 * * * *, deploy, chờ 15 phút, kiểm bảngnhat_ky_croncó đúng 3 lượt chạy. - Thử nghiệm hỏng: cố ý cho địa chỉ dịch vụ ngoài sai → kiểm
so_lan_loităng dần và dừng ở 5, không chạy vô tận. - Thử nghiệm chạy đôi: bấm "Chạy ngay" hai lần liên tiếp → khóa phải chặn lượt thứ hai, và không có tin nhắn nào bị gửi hai lần.