服务器上要定期跑的事情不少:每天备份数据库、每周清理日志、每小时同步一次数据、证书到期前自动续期。这些都靠定时任务。
Linux 上有两套方案 —— 传统的 cron 和 systemd timer。网上教程大多只讲其中一个,但实际用下来两个都有各自更合适的场景。这篇把语法、取舍、以及那些"任务写对了却没按点跑"的原因讲清楚。
站内已经有一篇 定时任务不执行怎么办 是排查清单,这篇讲的是怎么正确配置。出问题了看那篇,要配置看这篇。
| cron | systemd timer | |
|---|---|---|
| 配置复杂度 | 一行搞定 | 要写两个文件 |
| 语法直观度 | 五个字段,需要记 | 接近自然语言 |
| 日志 | 要自己重定向 | 自动进 journal |
| 错过了会补跑吗 | 不会 | 可以(Persistent=true) |
| 防止任务重叠 | 要自己加锁 | 默认不会重叠 |
| 依赖关系 | 不支持 | 支持(After=、Requires=) |
| 触发精度 | 精确到分钟 | 默认有最多 1 分钟误差 |
| 容器里可用 | 要装 cron | 通常不可用 |
怎么选:
- 简单的周期任务(每天备份、每小时同步)→ cron,一行就完事
- 要看执行日志、要错过补跑、任务本身是个服务 → systemd timer
- 机器经常关机或重启(比如开发机)→ systemd timer,
Persistent=true能补跑 - 容器里 → 都不太合适,用宿主机的定时任务去
docker exec,或者用应用自带的调度器
个人 VPS 上做备份、清理这类事,cron 完全够用,别被"systemd 更现代"带偏。
┌─── 分钟 (0-59)
│ ┌─── 小时 (0-23)
│ │ ┌─── 日期 (1-31)
│ │ │ ┌─── 月份 (1-12)
│ │ │ │ ┌─── 星期 (0-7,0 和 7 都是周日)
│ │ │ │ │
* * * * * 要执行的命令
常用写法:
| 表达式 | 含义 |
|---|---|
0 3 * * * | 每天凌晨 3:00 |
*/15 * * * * | 每 15 分钟 |
0 */6 * * * | 每 6 小时(0点、6点、12点、18点) |
30 2 * * 0 | 每周日凌晨 2:30 |
0 4 1 * * | 每月 1 号凌晨 4:00 |
0 9-18 * * 1-5 | 工作日 9 点到 18 点,每小时一次 |
15 2 * * 1,3,5 | 周一、三、五凌晨 2:15 |
还有几个快捷写法,可读性更好:
@daily /srv/scripts/backup.sh # 等于 0 0 * * *
@hourly /srv/scripts/sync.sh # 等于 0 * * * *
@weekly /srv/scripts/cleanup.sh # 等于 0 0 * * 0
@reboot /srv/scripts/startup.sh # 开机后执行一次
@reboot 不是用来做开机自启的。它只在 cron 服务启动时跑一次,时机不受控 —— 可能网络还没就绪、依赖的服务还没起来。要开机自启请用 systemd 服务,见 systemd 服务启动失败怎么办 里的 unit 写法。
这条是 cron 里最反直觉的规则:
0 0 13 * 5 # 每月 13 号执行,「或者」每周五执行
不是"13 号且是周五",而是满足任一条件就执行。想要"13 号且是周五",只能在脚本里自己判断:
0 0 13 * * [ "$(date +\%u)" = "5" ] && /srv/scripts/task.sh
注意上面的 \% —— 下面马上讲。
这是 crontab 里最常见的语法坑。 在 crontab 中,% 有特殊含义(表示换行,后面的内容会作为标准输入传给命令)。所以这样写是错的:
# ❌ 错误:% 被当成换行符,命令会被截断
0 3 * * * tar czf /backup/$(date +%F).tar.gz /srv/app
必须转义成 \%:
# ✅ 正确
0 3 * * * tar czf /backup/$(date +\%F).tar.gz /srv/app
更省事的做法是把命令写进脚本文件,crontab 里只调脚本 —— 脚本里的 % 不需要转义,而且复杂逻辑也好维护。这是我推荐的默认做法。
cron 执行任务时用的环境和你 SSH 登录时完全不同。它的 PATH 通常只有 /usr/bin:/bin,你在终端里能跑的命令,在 cron 里可能报 command not found。
三种处理方式,从好到差:
# 1. 脚本里用绝对路径(最稳)
/usr/bin/docker compose -f /srv/app/compose.yml up -d
# 2. crontab 顶部设 PATH
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * docker compose -f /srv/app/compose.yml up -d
# 3. 脚本开头 source 环境(依赖 shell 配置,最不稳)
用 which docker 查绝对路径,然后写进脚本里。nvm 装的 node、pyenv 装的 python 尤其容易踩这个坑,因为它们的路径是在 shell 启动时才注入的。
默认情况下 cron 会把任务的输出通过邮件发给用户 —— 而 VPS 上通常没配邮件系统,结果就是输出丢失,或者堆在 /var/mail/ 里慢慢占空间。
# ✅ 正常输出和错误都写进日志
0 3 * * * /srv/scripts/backup.sh >> /var/log/backup.log 2>&1
# 只要错误,成功时不记
0 3 * * * /srv/scripts/backup.sh > /dev/null
# ❌ 全部丢弃 —— 出错了你永远不知道
0 3 * * * /srv/scripts/backup.sh > /dev/null 2>&1
2>&1 的位置很重要,必须在 >> 之后。写成 2>&1 >> file 的话,错误还是会输出到原来的地方。
日志文件会一直涨,记得配轮转(下面有)。
crontab -e # 编辑当前用户的
crontab -l # 查看
crontab -r # 删除全部(危险,没有二次确认)
sudo crontab -e # 编辑 root 的
sudo crontab -u www-data -e # 编辑指定用户的
crontab -r 没有确认提示,手滑就全没了。 养成先 crontab -l > ~/crontab.bak 备份的习惯。
用户很关键:crontab -e 和 sudo crontab -e 编辑的是两份不同的任务表。任务没跑起来时,先确认你查的是不是编辑的那一份。
系统级的任务也可以直接放文件:
# /etc/cron.d/backup —— 注意这里多一个「用户」字段
0 3 * * * root /srv/scripts/backup.sh >> /var/log/backup.log 2>&1
/etc/cron.d/ 下的文件格式和 crontab -e 的不一样 —— 时间字段后面要多写一个执行用户。少写这个字段是另一个常见错误。
systemd timer 需要一对文件:.service 定义做什么,.timer 定义什么时候做。
/etc/systemd/system/backup.service:
[Unit]
Description=Daily backup job
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
ExecStart=/srv/scripts/backup.sh
/etc/systemd/system/backup.timer:
[Unit]
Description=Run backup daily at 3am
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
启用:
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
注意 enable 的是 .timer 不是 .service —— service 由 timer 触发,自己不需要 enable。
格式是 星期 年-月-日 时:分:秒,星期可省略。
| 表达式 | 含义 |
|---|---|
*-*-* 03:00:00 | 每天 3:00 |
*-*-* *:00:00 | 每小时整点 |
*-*-* *:00/15:00 | 每 15 分钟 |
Mon *-*-* 02:30:00 | 每周一 2:30 |
*-*-01 04:00:00 | 每月 1 号 4:00 |
Mon..Fri *-*-* 09:00:00 | 工作日 9:00 |
*-01,04,07,10-01 00:00:00 | 每季度第一天 |
还有一组简写(官方定义的等价形式):
| 简写 | 等价于 |
|---|---|
minutely | *-*-* *:*:00 |
hourly | *-*-* *:00:00 |
daily | *-*-* 00:00:00 |
weekly | Mon *-*-* 00:00:00 |
monthly | *-*-01 00:00:00 |
yearly | *-01-01 00:00:00 |
quarterly | *-01,04,07,10-01 00:00:00 |
写完一定要验证,这个命令会告诉你接下来几次的实际触发时间:
systemd-analyze calendar "Mon..Fri *-*-* 09:00:00" --iterations=3
比起 cron 的"写完等明天看看跑没跑",这是 systemd timer 的一大优势。
Persistent=true —— 把上次触发时间记在磁盘上。如果机器在计划时间处于关机状态,下次启动后会立即补跑一次。这是 cron 完全没有的能力(cron 只会错过就算了)。做备份这类任务时建议开。
RandomizedDelaySec=300 —— 在计划时间基础上随机延迟 0-300 秒。看起来没用,但如果你有多台机器在同一时刻跑备份、或者多个任务都定在整点,加上它能避免负载尖峰和请求撞车。
AccuracySec= —— 默认是 1 分钟,意味着任务可能在计划时间之后最多 1 分钟才触发。systemd 这么做是为了把多个定时器合并唤醒、省电。需要精确到秒就显式设置:
[Timer]
OnCalendar=*-*-* 03:00:00
AccuracySec=1s
很多人以为 systemd timer 和 cron 一样准时,实际默认不是 —— 这是"日志时间总是差几十秒"的原因。
# 列出所有定时器,包括下次触发时间和上次执行时间
systemctl list-timers --all
# 只看某一个
systemctl list-timers backup.timer
# 看执行日志(这是 systemd timer 最大的好处)
journalctl -u backup.service -n 50 --no-pager
# 手动触发一次测试(不用等到点)
sudo systemctl start backup.service
systemctl list-timers 应该是你配完之后的第一个动作 —— 它会直接显示 NEXT(下次触发)和 LAST(上次执行),一眼看出配得对不对。
不管用 cron 还是 timer,脚本本身有几个必须做的事。
#!/usr/bin/env bash
set -euo pipefail
-e出错就退出,别带着错误继续跑-u用了未定义变量就报错-o pipefail管道里任一环节失败就算失败
不加这些,脚本会"看起来成功"地做错事 —— 比如 mysqldump 失败了但后面的 gzip 照常执行,你得到一个空的备份文件,而且退出码是 0。
# ❌ 依赖当前目录和 PATH
cd app && ./backup.sh
# ✅
cd /srv/app || exit 1
/usr/bin/mysqldump ...
任务跑得比间隔还慢时,第二个实例会在第一个还没结束时启动 —— 两个 rsync 同时写一个目录,或者两个备份同时导数据库,结果都很难看。
cron 用 flock:
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /srv/scripts/sync.sh
-n 表示拿不到锁就直接退出(而不是排队等待)。锁文件在脚本结束时自动释放,就算脚本崩了也不会残留死锁。
systemd timer 默认就不会重叠 —— 如果上一次的 service 还在运行,这次触发会被跳过。这是它相对 cron 的一个实在优势。
log() { echo "[$(date '+%F %T')] $*"; }
log "backup started"
# ... 实际工作
log "backup finished, size=$(du -h "$DEST" | cut -f1)"
排查问题时,"这个任务昨天到底跑没跑、跑了多久"是最先要回答的问题。
任务静默失败是最糟的情况 —— 你以为备份了三个月,真要恢复时发现一个都没有。
#!/usr/bin/env bash
set -euo pipefail
notify_fail() {
curl -fsS -X POST "https://your-webhook-url" \
-d "text=backup.sh 失败,主机 $(hostname),退出码 $?" || true
}
trap notify_fail ERR
# ... 实际工作
trap ... ERR 会在脚本出错时触发。webhook 可以用 Telegram Bot、飞书、Slack 或者任何能收 POST 的地方。末尾的 || true 别省 —— 通知本身失败时不要让脚本再次报错。
更完整的做法是用「死人开关」(healthchecks.io 这类):任务成功时 ping 一下,长时间没收到 ping 就告警 —— 这样连"任务根本没触发"都能发现,比只在失败时通知更可靠。
#!/usr/bin/env bash
# /srv/scripts/backup-db.sh
set -euo pipefail
DEST=/srv/backups
KEEP_DAYS=14
STAMP=$(date +%F-%H%M)
mkdir -p "$DEST"
# --single-transaction 避免锁表
/usr/bin/mysqldump --single-transaction --quick \
--databases myapp \
| gzip > "$DEST/myapp-$STAMP.sql.gz"
# 校验产物不是空的
[ -s "$DEST/myapp-$STAMP.sql.gz" ] || { echo "backup is empty!"; exit 1; }
# 清理超过保留期的旧备份
find "$DEST" -name 'myapp-*.sql.gz' -mtime +$KEEP_DAYS -delete
30 3 * * * /srv/scripts/backup-db.sh >> /var/log/backup-db.log 2>&1
那行 [ -s ... ] 检查很重要 —— 没有它,mysqldump 权限不足时你会得到一堆 20 字节的"备份文件",而且清理逻辑照常把旧的真备份删掉。
备份要传到别的地方,留在同一台机器上等于没备份。配合 rsync 推到另一台,或者传对象存储,做法见 VPS 文件传输指南。而且必须演练过恢复才算数,见 VPS 备份恢复演练。
# 每周日清理 30 天前的日志
0 4 * * 0 find /var/log/myapp -name '*.log' -mtime +30 -delete
但更好的做法是用 logrotate,它是专门干这个的,还能压缩和保留轮次:
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
}
logrotate 本身由系统的定时任务调用,不需要你另外配。
Docker 的日志是另一回事,见 Docker 占满磁盘怎么办。
certbot 装完就自带定时任务,不需要你自己写:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
自己加一条反而可能重复执行。要确认它真的在跑,用上面两条命令。续期失败的排查见 Let's Encrypt 证书续期失败怎么办。
不要在容器里装 cron。 容器的设计是一个进程一件事,塞个 cron 进去会让日志、信号处理、重启策略都变复杂。
正确做法是在宿主机上定时 exec 进去:
0 3 * * * /usr/bin/docker exec myapp-container /app/scripts/task.sh >> /var/log/task.log 2>&1
或者用 docker compose run --rm 起一个一次性容器跑任务。
任务没按时跑,怎么先定位?
# cron 的执行记录
grep CRON /var/log/syslog | tail -20
# 或者(RHEL 系)
sudo journalctl -u crond -n 30
# systemd timer
systemctl list-timers --all
journalctl -u backup.service -n 50
完整的排查顺序见 定时任务不执行怎么办。
时间对不上,早了或晚了几小时
服务器时区不对。很多云镜像默认是 UTC:
timedatectl # 看当前时区
sudo timedatectl set-timezone Asia/Shanghai
改完时区要重启 cron(sudo systemctl restart cron),否则已加载的任务还按旧时区算。时间不同步的其他原因见 VPS 时间不准怎么办。
能精确到秒吗?
cron 不能,最小粒度是分钟。要更细的间隔,两个办法:
# 用 sleep 错开(粗糙但能用)
* * * * * /srv/scripts/task.sh
* * * * * sleep 30; /srv/scripts/task.sh
或者用 systemd timer 的 OnUnitActiveSec=30s 配合 AccuracySec=1s。但要先想清楚是不是真的需要 —— 秒级任务通常应该做成常驻服务,而不是反复启动进程。
任务跑太久会怎样?
cron 不管,会起第二个实例(所以要用 flock)。systemd timer 会跳过本次触发。
怎么临时停掉一个任务?
# cron:注释掉那一行
crontab -e
# systemd
sudo systemctl stop backup.timer # 停止(重启后会恢复)
sudo systemctl disable backup.timer # 彻底禁用
WordPress 的 wp-cron 要不要关?
WordPress 默认的 wp-cron 是"有人访问时才触发",站点没流量时定时任务就不跑,流量大时又会频繁触发拖慢响应。标准做法是关掉它,改用系统 cron:
// wp-config.php
define('DISABLE_WP_CRON', true);
*/5 * * * * /usr/bin/curl -fsS "https://example.com/wp-cron.php?doing_wp_cron" > /dev/null
WordPress 的其他性能优化见 VPS 上 WordPress 很慢怎么办。
多台服务器的任务怎么管?
超过三五台之后,散落在各机器上的 crontab 会变得难以追踪。可以把 crontab 内容纳入版本控制(每台机器一个文件,用 crontab file 导入),或者上配置管理工具。至少要做到:所有定时任务的脚本都在 git 里,而不是只存在于某台服务器上。
任务需要 root 权限吗?
能不用就不用。备份网站文件用网站自己的用户就够,只有涉及系统级操作(写 /var/log、重启服务)才需要 root。权限最小化的思路见 VPS 安全加固指南。
- 脚本能手动跑通(
bash -x script.sh看每一步) - 脚本里用的是绝对路径
- 加了
set -euo pipefail - 输出重定向到日志,而不是
> /dev/null 2>&1全丢 - 日志配了轮转,不会无限增长
- 高频任务加了
flock防重叠 - 时区确认过(
timedatectl) - 用
systemctl list-timers或等一个周期,确认真的触发了 - 关键任务有失败通知
- 备份类任务验证过能恢复
最后一条最容易被跳过,也最致命。
- VPS 定时任务不执行怎么办 — 配好了但没跑,按这个清单查
- VPS 备份恢复演练怎么做 — 定时备份的下一步是验证能恢复
- VPS 文件传输指南 — 定时把备份推到异地
- VPS 日志怎么看 — journalctl 的完整用法
- VPS 自动安全更新和重启计划 — 系统更新也是一种定时任务
