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/Shanghai',
});

陷阱三: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#时区#运维