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

操作步驟

  1. 開啟 Cron 表達式工具
  2. 輸入 Cron 表達式
  3. 查看中文解讀:
    • 0 9 * * 1-5 → 「每週一至週五 09:00」
    • */30 8-18 * * * → 「每天 08:00-18:00 每 30 分鐘」
  4. 查看下次執行時間列表(未來 5-10 次)
  5. 驗證表達式是否合法

四、生產級排程任務設計

並行控制

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 號

七、最佳實踐清單

  1. 始終使用 UTC 時區,除非業務強依賴本地時間
  2. 日和星期不同時指定,避免聯集陷阱
  3. 並行控制:同一時刻只有一個實例在執行
  4. 分散式鎖:多實例部署時防止重複執行
  5. 失敗重試 + 告警:不靜默失敗
  6. 逾時控制:防止任務無限掛起
  7. 冪等設計:重複執行結果一致
  8. 執行日誌:記錄每次執行的時間、耗時、狀態
  9. 用工具驗證表達式:部署前確認語義正確
#Cron#定时任务#crontab#时区#运维