Cron 表达式 vs setInterval:定时任务的两种世界观
Cron 表达式 vs setInterval:定时任务的两种世界观
"每天凌晨 3 点跑一次清理任务"——这种需求在不同代码库里有截然不同的实现:
// 前端代码
setInterval(cleanup, 24 * 60 * 60 * 1000);
# 服务器 crontab
0 3 * * * /usr/local/bin/cleanup.sh
# Kubernetes CronJob
schedule: "0 3 * * *"
它们都在解决"周期性执行"问题,但底层世界观完全不同。本文讲清楚这个差异,并给出选型决策。
setInterval:相对时间的"心跳"
setInterval(fn, ms) 的语义是:从现在起,每过 ms 毫秒调用一次 fn。注意"从现在起"——它不知道现在是几点。
// 11:59:00 启动
setInterval(cleanup, 60 * 1000);
// → 12:00:00, 12:01:00, 12:02:00 ...
// 如果 11:59:30 启动 → 12:00:30, 12:01:30, 12:02:30 ...
这导致几个特性:
- 不能表达"绝对时间"——想要"每天 3:00:00 整执行"必须自己算 delta,每次执行后重新 schedule
- 进程重启即归零——刷新页面 / pod 重启 / serverless 冷启动后,timer 完全重来
- 不允许跳过执行——即使函数执行了 10 秒钟,下次调用仍然按"上次实际开始 + interval"算,而不是按"上次开始 + interval"
第 3 点的细节很关键。看这段代码:
setInterval(async () => {
await fetch("/api/heavy-task"); // 假设这个请求要 5 秒
}, 1000);
JavaScript 的 setInterval 在浏览器和 Node.js 里都是 不会等上次完成 的——它每秒发起一个新请求,1 秒后即使前一个请求还在路上也会再发一个。10 秒后你有 10 个并发请求在排队。
正确写法是用 setTimeout 自递归:
async function loop() {
try {
await fetch("/api/heavy-task");
} finally {
setTimeout(loop, 1000);
}
}
loop();
这是个老问题但每年仍然有新 bug。当 interval 远小于任务耗时,setInterval 等于"DDoS 自己的服务"。
Cron:绝对时间的"日程表"
Cron 表达式(最早可追溯到 1975 年 Unix V7)的语义是:在符合这个时间模式的每个时刻执行。
* * * * * command
│ │ │ │ │
│ │ │ │ └─ 星期几(0-7,0 和 7 都是周日)
│ │ │ └─── 月份(1-12)
│ │ └───── 几号(1-31)
│ └─────── 小时(0-23)
└───────── 分钟(0-59)
我们的 Cron 表达式工具 能可视化拼出表达式并预览未来 5 次执行时间。几个常用例子:
0 3 * * *— 每天 03:00*/15 * * * *— 每 15 分钟(00、15、30、45 分)0 9 * * 1-5— 工作日早上 9 点0 0 1 * *— 每月 1 号凌晨
Cron 的核心特性:
- 绝对时间——表达式描述"什么时刻该执行",与系统启动时间无关
- 进程无状态——cron daemon 每分钟读一次表达式列表,匹配到当前时刻就跑。重启不影响调度
- 不保证"补跑"——如果机器在 03:00 那一刻没开机,03:00 的任务就丢了(除非用 anacron 或 cronie 的 catch-up 模式)
关键差异:漂移(drift)
setInterval 在长时间运行下有可观察的漂移。考虑 setInterval(fn, 1000):
- 第 1 次:startTime + 1000ms
- 第 2 次:startTime + 1000ms + fn 执行耗时 + scheduler 调度延迟
- 第 N 次:startTime + N * (1000ms + 漂移累计)
浏览器还有更夸张的漂移源——后台 tab 节流。Chrome 在 inactive tab 里把所有 setInterval 强制限到 ≥ 1000ms。如果你写了 setInterval(fn, 100),切到别的 tab 后实际变成 1 秒一次。回到 tab 时不会"补跑"那些被节流掉的次数——直接丢。
Cron 没有漂移问题——daemon 每分钟检查一次,匹配上就跑。"每分钟"的精度是 1 秒级(取决于 daemon 实现),但下一分钟的判断永远基于墙上时钟而不是"上次执行 + 60 秒"。
实战影响:
- 需要"准点执行"(用户感知、SLA、对账)→ cron
- 需要"持续保持心跳"且对单次时间不敏感(健康检查、轮询)→ setInterval
- 需要"高频但不能漂移"(每 100ms 采样并对齐墙钟)→ 用
requestAnimationFrame或 worker timer + 时钟校正
Cron 表达式的语法陷阱
陷阱 1:day-of-month 和 day-of-week 是 OR 关系
0 0 13 * 5 # 想表达"每月 13 号 且 周五"
实际语义是 每月 13 号 或 任何周五凌晨——两个 day-of-* 字段在标准 Vixie cron 里是 OR。想表达 AND 必须在脚本里再判一次:
0 0 13 * * [ "$(date +\%u)" = "5" ] && /path/to/job.sh
或者用支持 ? 占位的扩展(Quartz、k8s):0 0 13 * ? 表示"忽略 day-of-week"。
陷阱 2:用户 vs 系统 crontab 语法差
# /etc/crontab(系统级,需要 user 字段)
0 3 * * * root /usr/local/bin/job.sh
# crontab -e(用户级,没有 user 字段)
0 3 * * * /usr/local/bin/job.sh
复制粘贴搞错就是 syntax error,日志里只说"bad command"不告诉你少了字段。
陷阱 3:环境变量不继承
cron 启动的 shell 是 极简环境——没有 $PATH、没有 $HOME、没有 ~/.bashrc 里的 alias。"在终端能跑,cron 里 not found" 99% 是 PATH 问题:
# crontab 顶部显式声明
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * cleanup.sh
什么时候 cron 也不够用
cron 解决"定时触发",但生产环境的定时任务往往还要:
- 失败重试(任务挂了自动重跑 3 次)
- 不重复执行(5 台机器都跑同一个 crontab,要 leader election)
- 可观测性(dashboard 看到上次执行时间、耗时、错误率)
- 依赖链(任务 A 跑完才能跑 B)
这时候纯 cron 就不够,要升级到 job queue / scheduler:
| 工具 | 语言生态 | 适合场景 |
|---|---|---|
| BullMQ | Node.js / Redis | Web 应用的延时任务 + 周期任务 |
| Sidekiq | Ruby / Redis | Rails 项目标配 |
| Celery + Beat | Python | 长任务 + cron-like 调度 |
| Temporal | 多语言 SDK | 复杂工作流(saga、依赖链、长时间运行) |
| Airflow | Python DAG | 数据 pipeline,重 ETL |
| Kubernetes CronJob | K8s | 简单的容器化定时任务 |
判断标准:如果任务失败一次没人会去手动重跑,就别用裸 cron。
实战决策树
前端 / 浏览器内:
- 心跳 / 轮询 →
setTimeout自递归(不要 setInterval) - 准点执行(如 23:59:59 倒计时归零)→ 算 delta 后单次 setTimeout
- 持续动画 →
requestAnimationFrame,与浏览器刷新对齐
单机 Linux/macOS:
- 简单清理脚本 → crontab
- 需要日志归档 → systemd timer(比 cron 多了 unit 隔离 + journald 集成)
容器 / 云原生:
- 单 pod 简单任务 → Kubernetes CronJob
- 多副本、需要单实例执行 → 在 K8s CronJob 里跑 + Redis 分布式锁
- 复杂工作流 → Temporal / Airflow
永远不要做的事:
- 把
setInterval(fn, 24 * 60 * 60 * 1000)写进 Node.js 服务里当"每天一次"——服务一重启就归零,时间不准 - 让 5 个相同 cron 任务在 5 台机器并发跑同一段逻辑(覆盖文件 / DB race)
- 在 cron 里跑超过 1 分钟的任务但忘了加锁(下次触发时上次还没跑完,并发跑)
总结
setInterval 和 cron 是 两种调度世界观 的代表:前者基于"相对心跳",后者基于"绝对日程表"。它们都能写出 bug,但 bug 的形态不同:
- setInterval 的 bug 是 漂移、堆积、丢失——长时间运行后偏离预期
- cron 的 bug 是 语法、环境、并发——一次性踩坑后基本稳定
选择哪个的判断标准很简单:这个任务的语义是"某个时刻执行",还是"以某个间隔执行"? 如果你需要解释起点,大概率应该用 cron。
延伸阅读
- Cron 表达式生成工具:可视化拼出表达式 + 预览未来执行时间
- crontab.guru:在线测试 cron 表达式语义
- MDN — Window.setInterval():浏览器 setInterval 的精度与节流规则