概述
定时任务是运维自动化的基础组件——日志轮转、数据备份、证书续期、健康检查、报表生成,几乎每个运维场景都离不开定时执行。大多数人对定时任务的认知停留在 crontab -e 加一行 0 2 * * * /path/to/script.sh,但这在生产环境中远远不够:任务失败了谁通知?执行超时了谁处理?多台机器上的任务怎么协调?从 cron 到 systemd timer 到分布式调度,逐步梳理定时任务的管理实践。
一、cron 语法与局限
1.1 cron 表达式
cron 表达式由 5 个字段组成:
┌──────── 分钟 (0-59)
│ ┌────── 小时 (0-23)
│ │ ┌──── 日 (1-31)
│ │ │ ┌── 月 (1-12)
│ │ │ │ ┌ 星期 (0-7, 0和7都是周日)
│ │ │ │ │
* * * * * command
| 表达式 | 含义 |
|---|---|
0 2 * * * | 每天凌晨 2:00 |
*/15 * * * * | 每 15 分钟 |
0 */6 * * * | 每 6 小时 |
0 0 * * 0 | 每周日 0:00 |
0 0 1 * * | 每月 1 号 0:00 |
30 3-5 * * * | 3:30, 4:30, 5:30 |
0 0 1 1,4,7,10 * | 每季度首月 1 号 |
@reboot | 系统启动时 |
@daily / @midnight | 每天 0:00 |
@weekly | 每周日 0:00 |
@monthly | 每月 1 号 0:00 |
@yearly / @annually | 每年 1 月 1 日 0:00 |
1.2 crontab 管理
# 编辑当前用户的 crontab
crontab -e
# 查看当前用户的 crontab
crontab -l
# 删除当前用户的所有定时任务
crontab -r
# 从文件导入 crontab
crontab cron_jobs.txt
# 编辑指定用户的 crontab(需要 root 权限)
sudo crontab -u deploy -e
# 系统级定时任务(所有用户可见)
sudo vim /etc/crontab
# 格式多一个用户字段:
# m h dom mon dow user command
0 2 * * * root /opt/scripts/backup.sh
# 目录式定时任务(按预设频率执行目录下的所有脚本)
/etc/cron.hourly/ # 每小时
/etc/cron.daily/ # 每天
/etc/cron.weekly/ # 每周
/etc/cron.monthly/ # 每月
1.3 cron 的局限
| 局限 | 说明 | 影响 |
|---|---|---|
| 无错误重试 | 任务失败后不会自动重试 | 临时故障导致任务漏执行 |
| 无执行超时 | 任务可以无限运行 | 慢任务占用资源,阻塞后续任务 |
| 无并发控制 | 同一任务可能并发执行 | 数据竞争、资源耗尽 |
| 日志不友好 | 输出通过邮件发送或丢失 | 排障困难 |
| 无依赖管理 | 任务之间无法定义依赖关系 | 需要手动保证执行顺序 |
| 精度仅到分钟 | 不支持秒级调度 | 无法满足高频任务需求 |
| 环境受限 | 默认 PATH 很短,无用户环境变量 | 脚本找不到命令 |
| 单机限制 | 只能在单台机器上运行 | 无法跨节点协调 |
# 典型的 cron 问题场景
# 问题1:环境变量缺失
0 2 * * * /opt/scripts/backup.sh
# backup.sh 内部用到了 python3,但 cron 的 PATH 不含 /usr/local/bin
# 解决:在脚本开头显式设置 PATH,或在 crontab 中定义
PATH=/usr/local/bin:/usr/bin:/bin
0 2 * * * /opt/scripts/backup.sh
# 问题2:并发执行
*/1 * * * * /opt/scripts/process_data.sh
# 如果 process_data.sh 执行超过 1 分钟,下次会并发启动
# 解决:用 flock 加文件锁
*/1 * * * * /usr/bin/flock -n /tmp/process_data.lock /opt/scripts/process_data.sh
# 问题3:输出丢失
0 2 * * * /opt/scripts/backup.sh
# 如果有 stderr 输出,cron 会尝试发邮件(如果没配邮件就丢了)
# 解决:重定向到日志文件
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# 问题4:执行失败无感知
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# 即使 backup.sh 退出码非 0,cron 也不关心
# 解决:脚本内部实现告警,或用 systemd timer
二、systemd timer
2.1 基本概念
systemd timer 由两个文件组成:
- service 文件:定义要执行的任务(和普通 systemd service 一样)
- timer 文件:定义触发时间
/etc/systemd/system/
├── backup.service # 任务定义
└── backup.timer # 定时触发器
2.2 创建 timer
# /etc/systemd/system/backup.service
[Unit]
Description=Database Backup Service
Documentation=file:///opt/scripts/backup.sh
After=network.target postgresql.service
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
ExecStartPost=/opt/scripts/notify-backup.sh
User=backup
Group=backup
# 资源限制
MemoryMax=512M
CPUQuota=50%
# 超时
TimeoutStartSec=3600
# 工作目录
WorkingDirectory=/opt/scripts
# 环境变量
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
Environment="BACKUP_DIR=/data/backups"
EnvironmentFile=-/etc/sysconfig/backup
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/backup.timer
[Unit]
Description=Run Database Backup Daily
Requires=backup.service
[Timer]
# 触发方式一:日历时间(类似 cron)
OnCalendar=*-*-* 02:00:00
# 触发方式二:相对时间
# OnBootSec=5min # 开机后 5 分钟
# OnUnitActiveSec=1h # 上次执行后 1 小时
# OnUnitInactiveSec=30min # 上次结束后 30 分钟
# 随机延迟(避免所有任务同时启动)
RandomizedDelaySec=300
# 持久化(错过的任务在启动后补执行)
Persistent=true
# 精度(减少唤醒频率,省电)
AccuracySec=1min
[Install]
WantedBy=timers.target
# 启用 timer
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
# 查看 timer 状态
systemctl status backup.timer
systemctl list-timers backup.timer
# 查看所有 timer
systemctl list-timers --all
# 手动触发
sudo systemctl start backup.service
# 查看执行日志
journalctl -u backup.service
journalctl -u backup.service --since today
journalctl -u backup.service -f
# 查看 timer 的下次触发时间
systemctl list-timers backup.timer
2.3 OnCalendar 语法
systemd 的日历时间表达式比 cron 更强大:
# 基本格式:DayOfWeek Year-Month-Day Hour:Minute:Second
OnCalendar=*-*-* 02:00:00 # 每天 2:00
OnCalendar=*-*-* 02,14:00:00 # 每天 2:00 和 14:00
OnCalendar=Mon..Fri 09:00:00 # 工作日 9:00
OnCalendar=Sat,Sun 02:00:00 # 周末 2:00
OnCalendar=*-*-01 00:00:00 # 每月 1 号
OnCalendar=*-01,04,07,10-01 00:00:00 # 每季度首月 1 号
OnCalendar=Mon *-*-* 09:00:00 # 每周一 9:00
OnCalendar=*:0/15 # 每 15 分钟(秒级精度)
OnCalendar=hourly # 每小时
OnCalendar=daily # 每天 00:00
OnCalendar=weekly # 每周一 00:00
OnCalendar=monthly # 每月 1 号 00:00
OnCalendar=yearly # 每年 1 月 1 日 00:00
# 验证日历表达式
systemd-analyze calendar "*-*-* 02:00:00"
systemd-analyze calendar "Mon..Fri 09:00:00"
2.4 cron vs systemd timer 对比
| 维度 | cron | systemd timer |
|---|---|---|
| 精度 | 分钟 | 秒 |
| 错误重试 | 无 | 通过 OnFailure= 实现 |
| 执行超时 | 无 | TimeoutStartSec= |
| 并发控制 | 需 flock | RefuseManualStart= + ConditionPathExists= |
| 日志 | 邮件或手动重定向 | journalctl 原生集成 |
| 依赖管理 | 无 | After=, Requires= |
| 环境变量 | 受限 | Environment=, EnvironmentFile= |
| 资源限制 | 无 | MemoryMax=, CPUQuota= |
| 用户隔离 | crontab -u | systemctl --user |
| 错过的任务 | 丢失 | Persistent=true 补执行 |
| 随机延迟 | 无 | RandomizedDelaySec= |
| 状态查询 | crontab -l | systemctl list-timers |
| 学习成本 | 低 | 中 |
结论:新项目优先用 systemd timer,已有 cron 环境可以逐步迁移。systemd timer 在错误处理、日志、资源控制方面的优势是质的提升。
三、任务设计原则
3.1 幂等性
定时任务必须幂等——无论执行多少次,结果一致:
#!/usr/bin/env bash
# ❌ 非幂等:每次追加数据
echo "backup data" >> /data/backup.log
# ✅ 幂等:使用固定文件名或带日期的文件名
echo "backup data" > "/data/backup-$(date +%Y%m%d).log"
# ✅ 幂等:先检查再执行
if [ -f /data/backup-done.flag ]; then
echo "今日已备份,跳过"
exit 0
fi
# 执行备份...
touch /data/backup-done.flag
3.2 并发控制
#!/usr/bin/env bash
set -euo pipefail
# 方法一:flock 文件锁
LOCK_FILE="/var/lock/$(basename "$0").lock"
exec 200>"$LOCK_FILE"
flock -n 200 || {
echo "另一个实例正在运行,退出"
exit 0
}
# 方法二:PID 文件锁
PID_FILE="/var/run/$(basename "$0" .sh).pid"
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo "进程 $PID 正在运行,退出"
exit 0
fi
fi
echo $$ > "$PID_FILE"
trap "rm -f $PID_FILE" EXIT
# systemd 方式:更优雅的并发控制
# /etc/systemd/system/data-sync.service
[Service]
Type=oneshot
ExecStart=/opt/scripts/sync-data.sh
# 防止手动重复启动
RefuseManualStart=no
# 如果已经在运行,不启动新实例(默认行为)
3.3 超时控制
#!/usr/bin/env bash
set -euo pipefail
TIMEOUT=300 # 5 分钟
# 方法一:timeout 命令
timeout "$TIMEOUT" /opt/scripts/long_task.sh
exit_code=$?
if [ $exit_code -eq 124 ]; then
echo "任务超时(${TIMEOUT}秒)"
# 发送告警
curl -sf -X POST "$WEBHOOK_URL" \
-d "{\"text\":\"任务超时: $(basename $0)\"}"
exit 1
fi
exit $exit_code
# 方法二:后台执行 + 超时检查
run_with_timeout() {
local timeout=$1; shift
local cmd="$*"
$cmd &
local pid=$!
local start=$(date +%s)
while kill -0 $pid 2>/dev/null; do
local elapsed=$(( $(date +%s) - start ))
if [ $elapsed -gt $timeout ]; then
echo "超时,终止进程 $pid"
kill -TERM $pid
sleep 2
kill -9 $pid 2>/dev/null || true
return 124
fi
sleep 5
done
wait $pid
return $?
}
# systemd 方式
[Service]
TimeoutStartSec=300
# 超时后 systemd 会发送 SIGTERM,然后 SIGKILL
3.4 环境隔离
#!/usr/bin/env bash
# 在脚本开头设置完整环境
set -euo pipefail
# 显式设置 PATH
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# 加载环境变量
if [ -f /etc/profile.d/app.sh ]; then
source /etc/profile.d/app.sh
fi
# 设置工作目录
cd /opt/app || { echo "无法切换到工作目录"; exit 1; }
# 设置 umask
umask 022
# 语言设置
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
四、失败重试与告警
4.1 systemd 失败重试
# /etc/systemd/system/critical-task.service
[Unit]
Description=Critical Task
OnFailure=critical-task-failed@%n.service
[Service]
Type=oneshot
ExecStart=/opt/scripts/critical-task.sh
# 自动重启(不适用于 oneshot,但可以配合 timer)
# Restart=on-failure
# RestartSec=30
# 资源限制
MemoryMax=1G
CPUQuota=100%
# 超时
TimeoutStartSec=600
# /etc/systemd/system/critical-task-failed@.service
# 模板服务:当任务失败时触发
[Unit]
Description=Handle Failure of %i
[Service]
Type=oneshot
ExecStart=/opt/scripts/alert-failure.sh %i
#!/usr/bin/env bash
# /opt/scripts/alert-failure.sh
# 任务失败告警脚本
FAILED_UNIT="$1"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
HOSTNAME=$(hostname)
# 获取失败日志
LOGS=$(journalctl -u "$FAILED_UNIT" --since "10 min ago" --no-pager -n 20 2>/dev/null)
# 发送告警
curl -sf -X POST "${WEBHOOK_URL}" \
-H 'Content-Type: application/json' \
-d "{
\"text\": \"定时任务失败告警\n服务: ${FAILED_UNIT}\n主机: ${HOSTNAME}\n时间: ${TIMESTAMP}\n日志:\n${LOGS}\"
}"
# 尝试重试
RETRY_FILE="/var/lib/task-retry/${FAILED_UNIT}.count"
mkdir -p /var/lib/task-retry
RETRY_COUNT=0
if [ -f "$RETRY_FILE" ]; then
RETRY_COUNT=$(cat "$RETRY_FILE")
fi
if [ "$RETRY_COUNT" -lt 3 ]; then
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "$RETRY_COUNT" > "$RETRY_FILE"
echo "第 ${RETRY_COUNT} 次重试..."
sleep $((RETRY_COUNT * 30)) # 递增延迟
systemctl start "$FAILED_UNIT"
else
echo "重试次数已达上限(3次),需要人工介入"
# 升级告警
curl -sf -X POST "${ESCALATION_URL}" \
-d "{\"text\":\"任务 ${FAILED_UNIT} 重试3次仍失败,需要人工介入\"}"
fi
4.2 cron 任务告警包装器
#!/usr/bin/env bash
# /opt/scripts/cron-wrapper.sh
# cron 任务包装器:统一处理日志、超时、告警
set -euo pipefail
# 参数解析
TASK_NAME="$1"; shift
TIMEOUT="${TIMEOUT:-300}"
WEBHOOK_URL="${WEBHOOK_URL:-}"
LOG_DIR="/var/log/cron"
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/${TASK_NAME}-$(date +%Y%m%d).log"
START_TIME=$(date +%s)
echo "========================================" | tee -a "$LOG_FILE"
echo "任务: $TASK_NAME" | tee -a "$LOG_FILE"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$LOG_FILE"
echo "命令: $*" | tee -a "$LOG_FILE"
echo "========================================" | tee -a "$LOG_FILE"
# 执行任务(带超时)
set +e
timeout "$TIMEOUT" "$@" 2>&1 | tee -a "$LOG_FILE"
EXIT_CODE=${PIPESTATUS[0]}
set -e
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "========================================" | tee -a "$LOG_FILE"
echo "退出码: $EXIT_CODE" | tee -a "$LOG_FILE"
echo "耗时: ${DURATION}s" | tee -a "$LOG_FILE"
echo "========================================" | tee -a "$LOG_FILE"
# 处理结果
if [ $EXIT_CODE -eq 0 ]; then
echo "✓ 任务成功" | tee -a "$LOG_FILE"
# 更新成功标记
touch "/var/lib/cron-status/${TASK_NAME}.success"
rm -f "/var/lib/cron-status/${TASK_NAME}.failed"
else
echo "✗ 任务失败" | tee -a "$LOG_FILE"
# 记录失败
mkdir -p /var/lib/cron-status
echo "$(date +%s)" > "/var/lib/cron-status/${TASK_NAME}.failed"
# 发送告警
if [ -n "$WEBHOOK_URL" ]; then
FAILURE_LOG=$(tail -20 "$LOG_FILE")
curl -sf -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{
\"text\": \"⚠ Cron 任务失败\n任务: ${TASK_NAME}\n主机: $(hostname)\n退出码: ${EXIT_CODE}\n耗时: ${DURATION}s\n日志:\n${FAILURE_LOG}\"
}" 2>/dev/null || true
fi
fi
# 日志轮转
find "$LOG_DIR" -name "${TASK_NAME}-*.log" -mtime +30 -delete
exit $EXIT_CODE
# crontab 中使用包装器
0 2 * * * /opt/scripts/cron-wrapper.sh backup 300 /opt/scripts/backup.sh
*/15 * * * * /opt/scripts/cron-wrapper.sh health-check 60 /opt/scripts/check.sh
0 * * * * /opt/scripts/cron-wrapper.sh sync-data 600 /opt/scripts/sync.sh
五、日志集成
5.1 systemd journal 日志
# 查看 timer 执行历史
journalctl -u backup.service --since today
journalctl -u backup.service --since "2026-07-10" --until "2026-07-10 12:00"
# 只看错误
journalctl -u backup.service -p err
# 跟踪实时日志
journalctl -u backup.service -f
# 查看执行耗时
journalctl -u backup.service -o json | jq '. | select(.MESSAGE | contains("耗时"))'
# 查看所有 timer 的执行记录
journalctl -u '*.timer' --since today
# 导出日志
journalctl -u backup.service --since "2026-07-01" > backup-logs.txt
5.2 结构化日志
#!/usr/bin/env bash
# 在脚本中输出结构化日志
# 方法一:logger 命令(写入 syslog/journal)
logger -t backup "备份开始: target=${BACKUP_TARGET}"
# ... 执行备份 ...
logger -t backup "备份完成: size=${BACKUP_SIZE}, duration=${DURATION}s"
# 方法二:systemd-cat(支持优先级)
echo "备份开始" | systemd-cat -t backup -p info
echo "备份警告: 磁盘空间不足" | systemd-cat -t backup -p warning
echo "备份失败" | systemd-cat -t backup -p err
# 方法三:JSON 格式日志
log_json() {
local level="$1"; shift
local message="$*"
echo "{\"timestamp\":\"$(date -u +%FT%TZ)\",\"level\":\"${level}\",\"task\":\"backup\",\"message\":\"${message}\"}" | systemd-cat -t backup
}
log_json info "备份开始"
log_json info "备份完成: size=${BACKUP_SIZE}"
log_json error "备份失败: ${ERROR_MSG}"
5.3 日志聚合查询
# 查询所有定时任务的执行状态
systemctl list-timers --all --output=json | \
jq '.[] | {unit: .unit, next: .next, last: .last, result: .result}'
# 统计今日任务成功率
SUCCESS=$(journalctl --since today -u '*.service' --grep '✓ 任务成功' | wc -l)
FAILED=$(journalctl --since today -u '*.service' --grep '✗ 任务失败' | wc -l)
echo "今日定时任务: 成功=${SUCCESS} 失败=${FAILED}"
# 生成定时任务日报
generate_timer_report() {
echo "=== 定时任务日报 $(date +%Y-%m-%d) ==="
echo ""
systemctl list-timers --all | head -n -1 | while read -r line; do
unit=$(echo "$line" | awk '{print $1}')
if [[ "$unit" == *.timer ]]; then
service="${unit%.timer}.service"
last_run=$(journalctl -u "$service" --since today -o short-iso | tail -1)
result="N/A"
if journalctl -u "$service" --since today | grep -q "Succeeded"; then
result="✓"
elif journalctl -u "$service" --since today | grep -q "Failed"; then
result="✗"
fi
echo " $result $unit"
fi
done
}
六、分布式定时任务
6.1 单机 cron 的分布式困境
当任务需要跨多台机器协调时,单机 cron 无法胜任:
- 同一任务只能在一台机器上运行(需要分布式锁)
- 任务依赖跨机器(A 机器的任务完成后才能触发 B 机器的任务)
- 需要任务编排(DAG 依赖关系)
- 需要可视化执行历史和手动触发
6.2 方案对比
| 方案 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| cron + 分布式锁 | 简单场景 | 改造成本低 | 锁失效处理复杂 |
| systemd timer | 单机/每机独立 | 原生集成、功能强 | 无跨机协调 |
| XXL-Job | 中小规模分布式 | 轻量、中文友好、UI 好 | Java 生态、功能有限 |
| Airflow | 数据管道/复杂 DAG | 强大的 DAG 编排、丰富的 Operator | 重量级、学习曲线陡 |
| K8s CronJob | K8s 环境 | 原生集成、声明式 | 功能简单、无 DAG |
| Temporal | 长任务/工作流 | 代码即工作流、强一致性 | 较新、学习曲线 |
6.3 K8s CronJob
# k8s-cronjob-backup.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: database-backup
namespace: production
spec:
schedule: "0 2 * * *"
# 成功任务保留数
successfulJobsHistoryLimit: 3
# 失败任务保留数
failedJobsHistoryLimit: 5
# 并发策略
# Allow: 允许并发(默认)
# Forbid: 禁止并发
# Replace: 替换正在运行的旧任务
concurrencyPolicy: Forbid
# 任务超时
startingDeadlineSeconds: 200
# 暂停
# suspend: false
jobTemplate:
spec:
# 重试次数
backoffLimit: 3
# 任务超时
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: backup-tool:1.0
command:
- /bin/bash
- -c
- |
set -euo pipefail
echo "开始备份..."
pg_dump "$DATABASE_URL" > /backup/db-$(date +%Y%m%d).sql
echo "备份完成"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources:
requests:
memory: 256Mi
cpu: 200m
limits:
memory: 512Mi
cpu: 500m
volumeMounts:
- name: backup-storage
mountPath: /backup
volumes:
- name: backup-storage
persistentVolumeClaim:
claimName: backup-pvc
# 管理 K8s CronJob
kubectl create -f k8s-cronjob-backup.yaml
# 查看 CronJob
kubectl get cronjob -n production
# 查看执行历史
kubectl get jobs -n production
# 查看 Pod 日志
kubectl logs -n production job/database-backup-XXXXX
# 手动触发
kubectl create job --from=cronjob/database-backup manual-backup -n production
# 暂停
kubectl patch cronjob database-backup -n production -p '{"spec":{"suspend":true}}'
# 恢复
kubectl patch cronjob database-backup -n production -p '{"spec":{"suspend":false}}'
6.4 XXL-Job
// XXL-Job 任务示例(Java)
@XxlJob("databaseBackupHandler")
public void databaseBackup() {
String param = XxlJobHelper.getJobParam();
XxlJobHelper.log("开始数据库备份, 参数: {}", param);
try {
// 执行备份逻辑
String result = backupService.backup(param);
XxlJobHelper.log("备份完成: {}", result);
XxlJobHelper.handleSuccess(result);
} catch (Exception e) {
XxlJobHelper.log("备份失败: {}", e.getMessage());
XxlJobHelper.handleFail(e.getMessage());
}
}
XXL-Job 的核心优势:
- 可视化调度中心:Web UI 管理所有任务,支持手动触发、暂停、查看执行日志
- 失败告警:任务失败后自动邮件/钉钉告警
- 路由策略:第一个、轮询、随机、分片广播等
- 任务分片:大数据量任务分片到多个执行器并行处理
- 动态参数:支持在 UI 中动态修改任务参数
6.5 Airflow DAG
# airflow_dags/etl_pipeline.py
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from airflow.providers.ssh.operators.ssh import SSHOperator
from datetime import datetime, timedelta
default_args = {
'owner': 'data-team',
'depends_on_past': False,
'email': ['data-alerts@example.com'],
'email_on_failure': True,
'email_on_retry': False,
'retries': 3,
'retry_delay': timedelta(minutes=5),
'retry_exponential_backoff': True,
'max_retry_delay': timedelta(minutes=30),
}
dag = DAG(
'etl_pipeline',
default_args=default_args,
description='每日 ETL 数据管道',
schedule_interval='0 2 * * *',
start_date=datetime(2026, 1, 1),
catchup=False, # 不补执行历史任务
max_active_runs=1, # 不允许并发
tags=['etl', 'production'],
)
# 任务1: 检查数据源可用性
check_source = BashOperator(
task_id='check_source',
bash_command='curl -sf http://source-api/health || exit 1',
dag=dag,
)
# 任务2: 提取数据
extract_data = SSHOperator(
task_id='extract_data',
ssh_conn_id='etl_server',
command='cd /opt/etl && python3 extract.py --date {{ ds }}',
dag=dag,
)
# 任务3: 转换数据
transform_data = SSHOperator(
task_id='transform_data',
ssh_conn_id='etl_server',
command='cd /opt/etl && python3 transform.py --date {{ ds }}',
dag=dag,
)
# 任务4: 加载数据
load_data = SSHOperator(
task_id='load_data',
ssh_conn_id='etl_server',
command='cd /opt/etl && python3 load.py --date {{ ds }}',
dag=dag,
)
# 任务5: 数据质量检查
def check_quality(**context):
import requests
ds = context['ds']
resp = requests.get(f'http://quality-service/check?date={ds}')
if resp.status_code != 200 or resp.json()['score'] < 0.95:
raise ValueError(f"数据质量检查失败: {resp.json()}")
return resp.json()
quality_check = PythonOperator(
task_id='quality_check',
python_callable=check_quality,
dag=dag,
)
# 任务6: 发送通知
notify = BashOperator(
task_id='notify',
bash_command='curl -sf -X POST $WEBHOOK_URL -d "ETL 管道完成: {{ ds }}"',
dag=dag,
)
# 定义依赖关系(DAG)
check_source >> extract_data >> transform_data >> load_data >> quality_check >> notify
Airflow DAG 的核心优势:
- DAG 编排:用 Python 代码定义任务依赖关系,支持复杂的工作流
- 丰富的 Operator:BashOperator、PythonOperator、SSHOperator、KubernetesPodOperator 等
- Jinja2 模板:
{{ ds }}、{{ prev_ds }}等模板变量自动注入执行日期 - 回填(Backfill):可以补执行历史日期的任务
- UI 可视化:DAG 图形化展示、执行历史、手动触发、日志查看
七、生产实践
7.1 定时任务清单管理
# /opt/scheduler/tasks.yaml - 定时任务清单(版本控制)
tasks:
- name: database-backup
description: "数据库每日备份"
schedule: "0 2 * * *"
command: /opt/scripts/backup.sh
timeout: 3600
retries: 3
alert_on_failure: true
owner: dba-team
enabled: true
- name: log-cleanup
description: "日志清理(保留30天)"
schedule: "0 4 * * *"
command: /opt/scripts/clean-logs.sh
timeout: 600
retries: 1
alert_on_failure: false
owner: ops-team
enabled: true
- name: ssl-check
description: "SSL 证书到期检查"
schedule: "0 9 * * *"
command: /opt/scripts/check-ssl.py
timeout: 120
retries: 2
alert_on_failure: true
owner: ops-team
enabled: true
- name: metrics-report
description: "生成每日监控报表"
schedule: "0 8 * * *"
command: /opt/scripts/generate-report.py
timeout: 300
retries: 2
alert_on_failure: true
owner: sre-team
enabled: true
7.2 从清单生成 systemd timer
#!/usr/bin/env python3
"""
从 YAML 清单生成 systemd timer 和 service 文件
"""
import yaml
import os
from pathlib import Path
SYSTEMD_DIR = "/etc/systemd/system"
def generate_service(task: dict) -> str:
"""生成 service 文件"""
return f"""[Unit]
Description={task['description']}
After=network.target
[Service]
Type=oneshot
ExecStart={task['command']}
User=root
TimeoutStartSec={task.get('timeout', 300)}
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
"""
def generate_timer(task: dict) -> str:
"""生成 timer 文件"""
persistent = "true" if task.get('persistent', True) else "false"
return f"""[Unit]
Description=Timer for {task['name']}
Requires={task['name']}.service
[Timer]
OnCalendar={cron_to_systemd(task['schedule'])}
Persistent={persistent}
RandomizedDelaySec=60
[Install]
WantedBy=timers.target
"""
def cron_to_systemd(cron_expr: str) -> str:
"""将 cron 表达式转换为 systemd OnCalendar"""
parts = cron_expr.split()
minute, hour, day, month, weekday = parts
# 简化转换(处理常见模式)
if cron_expr == "@daily":
return "daily"
elif cron_expr == "@weekly":
return "weekly"
elif cron_expr == "@monthly":
return "monthly"
result = []
if weekday != "*":
# 转换 0-7 到 Mon-Sun
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
if "/" in weekday:
result.append(days[int(weekday.split("/")[0]) - 1])
else:
result.append(days[int(weekday) % 7])
date_parts = []
if month != "*":
date_parts.append(month)
else:
date_parts.append("*")
if day != "*":
date_parts.append(day)
else:
date_parts.append("*")
date_parts.append("*")
result.append("-".join(date_parts))
time_parts = []
if hour != "*":
time_parts.append(hour)
else:
time_parts.append("*")
if minute != "*":
time_parts.append(f"{minute}:00")
else:
time_parts.append("*:00")
result.append(":".join(time_parts))
return " ".join(result)
def main():
with open("/opt/scheduler/tasks.yaml") as f:
config = yaml.safe_load(f)
for task in config['tasks']:
if not task.get('enabled', True):
continue
name = task['name']
service_content = generate_service(task)
timer_content = generate_timer(task)
service_path = Path(SYSTEMD_DIR) / f"{name}.service"
timer_path = Path(SYSTEMD_DIR) / f"{name}.timer"
with open(service_path, 'w') as f:
f.write(service_content)
with open(timer_path, 'w') as f:
f.write(timer_content)
print(f"生成: {service_path} + {timer_path}")
print("\n执行以下命令启用:")
print(" systemctl daemon-reload")
for task in config['tasks']:
if task.get('enabled', True):
print(f" systemctl enable --now {task['name']}.timer")
if __name__ == '__main__':
main()
7.3 定时任务巡检
#!/usr/bin/env bash
#
# check_timers.sh - 定时任务巡检脚本
#
set -euo pipefail
echo "============================================"
echo "定时任务巡检报告"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================"
# 1. 所有 timer 状态
echo -e "\n=== Timer 列表 ==="
systemctl list-timers --all --no-pager | head -30
# 2. 失败的任务
echo -e "\n=== 失败的任务 ==="
FAILED=$(systemctl list-units --type=service --state=failed --no-pager | grep -E '\.service' || true)
if [ -n "$FAILED" ]; then
echo "$FAILED"
else
echo "无失败任务"
fi
# 3. 今日执行情况
echo -e "\n=== 今日执行情况 ==="
TOTAL=0
SUCCESS=0
FAILED_COUNT=0
for timer in $(systemctl list-timers --all --no-pager | grep '\.timer' | awk '{print $1}'); do
service="${timer%.timer}"
TOTAL=$((TOTAL + 1))
if journalctl -u "$service" --since today --no-pager 2>/dev/null | grep -q "Succeeded\|Deactivated successfully"; then
SUCCESS=$((SUCCESS + 1))
elif journalctl -u "$service" --since today --no-pager 2>/dev/null | grep -q "Failed\|failed"; then
FAILED_COUNT=$((FAILED_COUNT + 1))
echo " ✗ $service"
fi
done
echo " 总计: $TOTAL 成功: $SUCCESS 失败: $FAILED_COUNT"
# 4. 检查 cron 任务
echo -e "\n=== Cron 任务 ==="
if command -v crontab &>/dev/null; then
for user in root $(cut -d: -f1 /etc/passwd); do
CRON_JOBS=$(crontab -u "$user" -l 2>/dev/null | grep -v '^#' | grep -v '^$' || true)
if [ -n "$CRON_JOBS" ]; then
echo " 用户 $user:"
echo "$CRON_JOBS" | while read -r line; do
echo " $line"
done
fi
done
fi
# 5. 检查遗漏执行
echo -e "\n=== 可能遗漏的任务 ==="
for timer in $(systemctl list-timers --all --no-pager | grep '\.timer' | awk '{print $1}'); do
service="${timer%.timer}"
LAST_RUN=$(systemctl show "$service" -p ActiveEnterTimestamp --value 2>/dev/null || echo "")
if [ -z "$LAST_RUN" ] || [ "$LAST_RUN" = "n/a" ]; then
echo " ⚠ $service 从未执行过"
fi
done
echo -e "\n============================================"
7.4 迁移 cron 到 systemd timer
#!/usr/bin/env bash
#
# migrate_cron_to_systemd.sh - 将 cron 任务迁移到 systemd timer
#
set -euo pipefail
SYSTEMD_DIR="/etc/systemd/system"
# 读取 crontab
CRON_FILE="${1:-/etc/crontab}"
if [ ! -f "$CRON_FILE" ]; then
echo "文件不存在: $CRON_FILE"
exit 1
fi
# 解析 cron 行(跳过注释和空行)
while IFS= read -r line; do
# 跳过注释和空行
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
# 解析字段: m h dom mon dow user command
read -r minute hour day month weekday user command <<< "$line"
# 从命令中提取任务名
task_name=$(echo "$command" | awk '{print $1}' | xargs basename | sed 's/\.sh$//')
echo "迁移: $task_name ($minute $hour $day $month $weekday)"
# 生成 service 文件
cat > "${SYSTEMD_DIR}/${task_name}.service" << EOF
[Unit]
Description=Migrated from cron: ${task_name}
After=network.target
[Service]
Type=oneshot
ExecStart=${command}
User=${user}
TimeoutStartSec=3600
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
EOF
# 生成 timer 文件
cat > "${SYSTEMD_DIR}/${task_name}.timer" << EOF
[Unit]
Description=Timer for ${task_name}
Requires=${task_name}.service
[Timer]
OnCalendar=${minute}:${hour} *-*-${day:-*}-${month:-*} ${weekday:-*}
Persistent=true
RandomizedDelaySec=60
[Install]
WantedBy=timers.target
EOF
echo " -> ${SYSTEMD_DIR}/${task_name}.service"
echo " -> ${SYSTEMD_DIR}/${task_name}.timer"
done < "$CRON_FILE"
echo ""
echo "迁移完成。执行以下命令启用:"
echo " systemctl daemon-reload"
echo " # 逐个启用(建议先测试再启用)"
echo " systemctl enable --now <task-name>.timer"
总结
定时任务看似简单——不就是定时跑个脚本吗——但生产环境中的定时任务管理是一个系统工程。从 cron 到 systemd timer 到分布式调度,每一层都在解决前一层的局限。回顾本文核心要点:
- cron 够用但不够好:cron 的语法简单、到处都有,但缺乏错误重试、超时控制、并发防护和日志集成。临时任务用 cron 可以,关键任务别只靠 cron
- systemd timer 是更好的单机方案:journal 日志集成、资源限制、持久化补执行、随机延迟防雪崩——这些特性让定时任务从"能跑"变成"可信赖"。新项目优先选 systemd timer
- 任务设计四原则:幂等性(重跑不出问题)、并发控制(flock 或 systemd)、超时保护(timeout 或 TimeoutStartSec)、环境隔离(显式设置 PATH 和环境变量)
- 失败必须有告警:cron 任务失败默认静默,这是最大的隐患。用包装器脚本统一处理日志和告警,或用 systemd 的
OnFailure=机制 - 分布式场景需要专业工具:K8s CronJob 适合 K8s 原生场景、XXL-Job 适合中小规模 Java 团队、Airflow 适合复杂数据管道 DAG。选择取决于团队技术栈和任务复杂度
- 任务清单要版本控制:把所有定时任务的配置(名称、调度时间、命令、超时、负责人)维护在 YAML 文件中版本控制,配合脚本自动生成 systemd 配置。这样任务变更有 diff 可审查,团队任何人都能看到"系统上跑了哪些定时任务"
- 定期巡检不可少:定时任务也需要被监控。巡检脚本检查"哪些任务从没执行过"“哪些任务今天失败了"“哪些任务执行时间异常变长”,确保定时任务系统本身是健康的
定时任务是自动化运维的基础设施,它的可靠性直接影响业务的稳定性。当你把每个定时任务都纳入版本控制、配上告警、定期巡检时,“凌晨3点被叫起来因为某个 cron 脚本挂了"这种事就会越来越少。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- cron Wikipedia — En,参考了cron Wikipedia相关内容
- systemd.timer 官方文档 — freedesktop.org 社区,参考了systemd.timer 官方文档相关内容