Cron 表達式最佳實踐:從入門避坑到生產級排程任務設計
實用工具趙婉清
Cron 表達式:5 個欄位掌控時間
┌───────── 分鐘 (0-59)
│ ┌───────── 小時 (0-23)
│ │ ┌───────── 日 (1-31)
│ │ │ ┌───────── 月 (1-12)
│ │ │ │ ┌───────── 星期 (0-7, 0和7都是週日)
│ │ │ │ │
* * * * *
一、語法速查
| 表達式 | 含義 | 執行頻率 |
|---|---|---|
* * * * * |
每分鐘 | 1440 次/天 |
*/5 * * * * |
每 5 分鐘 | 288 次/天 |
0 * * * * |
每小時整點 | 24 次/天 |
0 9 * * * |
每天 9:00 | 1 次/天 |
0 9 * * 1-5 |
工作日 9:00 | 5 次/週 |
30 2 1 * * |
每月 1 號 2:30 | 1 次/月 |
0 0 1 1 * |
每年 1 月 1 日 0:00 | 1 次/年 |
特殊字元
| 字元 | 含義 | 範例 |
|---|---|---|
* |
任意值 | * * * * * |
, |
列舉 | 0 9,12,18 * * *(9/12/18點) |
- |
範圍 | 0 9-17 * * 1-5(工作日9-17點) |
/ |
步長 | */15 * * * *(每15分鐘) |
L |
最後 | 0 0 L * *(每月最後一天) |
W |
最近工作日 | 0 0 1W * *(1號最近的工作日) |
# |
第幾個星期 | 0 0 * * 5#3(第三個週五) |
二、常見陷阱
陷阱一:日和星期同時指定
# 錯誤理解:每月15號且是週五
0 0 15 * 5
# 實際含義:每月15號 OR 每週五(聯集!)
# 正確寫法需要額外邏輯判斷
規則:日和星期同時指定時,取聯集而非交集。
陷阱二:時區問題
# 期望台北時間 9:00 執行
0 9 * * *
# 伺服器在 UTC 時區 → 實際台北時間 17:00 執行!
# 解決:明確指定時區
// node-cron 指定時區
cron.schedule('0 9 * * *', task, {
scheduled: true,
timezone: 'Asia/Taipei',
});
陷阱三:60 秒誤寫
# 錯誤:分鐘欄位沒有 60
0 0 60 * * *
# 正確:每小時執行
0 * * * *
陷阱四:2月30號
# 2月沒有30號,此表達式在2月不會執行
0 0 30 2 *
陷阱五:夏令時間
夏令時間切換時,某些時間可能不存在或重複出現:
春季撥快:2:00 → 3:00(2:30 不存在)
秋季撥慢:2:00 → 1:00(1:30 執行兩次)
建議:使用 UTC 時區避免夏令時間問題。
三、使用工具庫驗證 Cron
操作步驟
- 開啟 Cron 表達式工具
- 輸入 Cron 表達式
- 查看中文解讀:
0 9 * * 1-5→ 「每週一至週五 09:00」*/30 8-18 * * *→ 「每天 08:00-18:00 每 30 分鐘」
- 查看下次執行時間列表(未來 5-10 次)
- 驗證表達式是否合法
四、生產級排程任務設計
並行控制
import { CronJob } from 'cron';
let isRunning = false;
const job = new CronJob('0 */5 * * * *', async () => {
if (isRunning) {
console.log('上一輪尚未完成,跳過本次執行');
return;
}
isRunning = true;
try {
await processData();
} finally {
isRunning = false;
}
});
分散式鎖(多實例部署)
import { Redis } from 'ioredis';
const redis = new Redis();
async function acquireLock(key, ttl = 300) {
const acquired = await redis.set(key, '1', 'EX', ttl, 'NX');
return acquired === 'OK';
}
const job = new CronJob('0 2 * * *', async () => {
const lockKey = `cron:daily-report:${new Date().toISOString().slice(0, 10)}`;
if (!(await acquireLock(lockKey))) {
console.log('其他實例已取得鎖,跳過');
return;
}
try {
await generateDailyReport();
} finally {
await redis.del(lockKey);
}
});
失敗重試
async function runWithRetry(task, maxRetries = 3, delay = 5000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await task();
} catch (error) {
console.error(`第 ${attempt} 次執行失敗:`, error.message);
if (attempt < maxRetries) {
await sleep(delay * attempt); // 遞增延遲
} else {
throw error;
}
}
}
}
const job = new CronJob('0 */10 * * * *', () => {
runWithRetry(syncData, 3, 5000);
});
逾時控制
async function runWithTimeout(task, timeoutMs = 60000) {
return Promise.race([
task(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('任務逾時')), timeoutMs)
),
]);
}
五、任務監控
執行日誌
const job = new CronJob('0 2 * * *', async () => {
const startTime = Date.now();
const context = { job: 'daily-sync', startedAt: new Date().toISOString() };
try {
await runWithRetry(syncData);
context.status = 'success';
} catch (error) {
context.status = 'failed';
context.error = error.message;
await sendAlert(context); // 告警
} finally {
context.duration = Date.now() - startTime;
await logExecution(context); // 記錄日誌
}
});
健康檢查
// 記錄最後成功執行時間
await redis.set('cron:last-success:daily-sync', Date.now());
// 健康檢查:如果超過 25 小時未執行,觸發告警
const lastSuccess = await redis.get('cron:last-success:daily-sync');
if (Date.now() - lastSuccess > 25 * 3600 * 1000) {
await sendAlert({ type: 'cron_stale', job: 'daily-sync' });
}
六、常見排程任務模式
| 需求 | Cron 表達式 | 說明 |
|---|---|---|
| 每小時資料同步 | 0 * * * * |
整點執行 |
| 工作日早報 | 0 9 * * 1-5 |
週一至週五 9:00 |
| 每日備份 | 0 2 * * * |
凌晨 2:00 |
| 每週清理 | 0 3 * * 0 |
週日 3:00 |
| 每月對帳 | 0 6 1 * * |
每月 1 號 6:00 |
| 每 15 分鐘心跳 | */15 * * * * |
高頻檢查 |
| 季度報表 | 0 8 1 1,4,7,10 * |
季度首月 1 號 |
七、最佳實踐清單
- 始終使用 UTC 時區,除非業務強依賴本地時間
- 日和星期不同時指定,避免聯集陷阱
- 並行控制:同一時刻只有一個實例在執行
- 分散式鎖:多實例部署時防止重複執行
- 失敗重試 + 告警:不靜默失敗
- 逾時控制:防止任務無限掛起
- 冪等設計:重複執行結果一致
- 執行日誌:記錄每次執行的時間、耗時、狀態
- 用工具驗證表達式:部署前確認語義正確
#Cron#定时任务#crontab#时区#运维