概述

定时任务是运维自动化的基础组件——日志轮转、数据备份、证书续期、健康检查、报表生成,几乎每个运维场景都离不开定时执行。大多数人对定时任务的认知停留在 crontab -e 加一行 0 2 * * * /path/to/script.sh,但这在生产环境中远远不够:任务失败了谁通知?执行超时了谁处理?多台机器上的任务怎么协调?从 cron 到 systemd timer 到分布式调度,逐步梳理定时任务的管理实践。

参考来源:cron Wikipediasystemd.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 对比

维度cronsystemd timer
精度分钟
错误重试通过 OnFailure= 实现
执行超时TimeoutStartSec=
并发控制需 flockRefuseManualStart= + ConditionPathExists=
日志邮件或手动重定向journalctl 原生集成
依赖管理After=, Requires=
环境变量受限Environment=, EnvironmentFile=
资源限制MemoryMax=, CPUQuota=
用户隔离crontab -usystemctl --user
错过的任务丢失Persistent=true 补执行
随机延迟RandomizedDelaySec=
状态查询crontab -lsystemctl 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 CronJobK8s 环境原生集成、声明式功能简单、无 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 到分布式调度,每一层都在解决前一层的局限。回顾本文核心要点:

  1. cron 够用但不够好:cron 的语法简单、到处都有,但缺乏错误重试、超时控制、并发防护和日志集成。临时任务用 cron 可以,关键任务别只靠 cron
  2. systemd timer 是更好的单机方案:journal 日志集成、资源限制、持久化补执行、随机延迟防雪崩——这些特性让定时任务从"能跑"变成"可信赖"。新项目优先选 systemd timer
  3. 任务设计四原则:幂等性(重跑不出问题)、并发控制(flock 或 systemd)、超时保护(timeout 或 TimeoutStartSec)、环境隔离(显式设置 PATH 和环境变量)
  4. 失败必须有告警:cron 任务失败默认静默,这是最大的隐患。用包装器脚本统一处理日志和告警,或用 systemd 的 OnFailure= 机制
  5. 分布式场景需要专业工具:K8s CronJob 适合 K8s 原生场景、XXL-Job 适合中小规模 Java 团队、Airflow 适合复杂数据管道 DAG。选择取决于团队技术栈和任务复杂度
  6. 任务清单要版本控制:把所有定时任务的配置(名称、调度时间、命令、超时、负责人)维护在 YAML 文件中版本控制,配合脚本自动生成 systemd 配置。这样任务变更有 diff 可审查,团队任何人都能看到"系统上跑了哪些定时任务"
  7. 定期巡检不可少:定时任务也需要被监控。巡检脚本检查"哪些任务从没执行过"“哪些任务今天失败了"“哪些任务执行时间异常变长”,确保定时任务系统本身是健康的

定时任务是自动化运维的基础设施,它的可靠性直接影响业务的稳定性。当你把每个定时任务都纳入版本控制、配上告警、定期巡检时,“凌晨3点被叫起来因为某个 cron 脚本挂了"这种事就会越来越少。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. cron Wikipedia — En,参考了cron Wikipedia相关内容
  2. systemd.timer 官方文档 — freedesktop.org 社区,参考了systemd.timer 官方文档相关内容