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
操作步骤
- 打开 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#时区#运维