小Cの已经记不起来的博客

任务调度 的十种打开方式:工程实践笔记

1. crontab:最土,也最容易背锅

之前一个清理过期临时文件的小需求,我第一反应就是 cron,因为太简单了。结果上线后发现它压根没跑,查了半天,脚本里用了相对路径,cron 默认环境变量也不全。后来老老实实写成这样:

*/10 * * * * cd /srv/app && /usr/bin/python3 cleanup.py >> /var/log/app/cleanup.log 2>&1

这种写法不算优雅,但很能活。cd /srv/app 是为了让脚本里的相对路径别再发疯,>> ... 2>&1 是为了下次出问题还能从日志里捞一点线索。cron 的坑基本集中在 PATH、时区、工作目录这三件事上,尤其容器里跑 cron,别想当然觉得它跟你本地一样。

2. systemd timer:把定时任务从“脚本”变成“服务”

如果机器已经用 systemd 管了,我会优先选 systemd timer。它不像 cron 那样一个文件里堆一堆任务,而是每个任务有 unit 和 timer,状态可以用 systemctl status xxx.timer 看,日志也跟 journal 走,排查起来没那么玄学。

大概长这样:

# /etc/systemd/system/cleanup.service
[Service]
Type=oneshot
WorkingDirectory=/srv/app
ExecStart=/usr/bin/python3 /srv/app/cleanup.py
# /etc/systemd/system/cleanup.timer
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true 这个挺实用,机器关了一段时间,醒来后还会把漏掉的一次补上,不会真的就“错过就算了”。不过 timer 也有一个坑:OnCalendar 的时间格式不是 cron,一开始写 30 3 * * * 那种,确实会懵。

3. Redis ZSET 延时任务:小项目别急着上平台

有些任务不是“每天几点执行”,而是“某个时间到期后执行一次”,比如 30 分钟后重试、优惠券过期、订单超时取消。这种我一般先拿 Redis ZSET 顶上,别一上来就引入复杂调度平台。

思路很简单,用时间戳当 score:

ZADD delay:queue 1780000000 "order:123:cancel"

消费端定期取到期任务:

ZRANGEBYSCORE delay:queue 0 1780000001 LIMIT 0 100

然后 ZREM 删掉再处理。这里要注意并发消费的问题,最好用 Lua 脚本原子地“取出并删除”,不然两个 worker 同时拿到同一条记录,业务就会开始表演。Redis 这个方案适合轻量场景,真复杂了还得往平台走。

4. Quartz:Java 老系统里绕不开它

Java 项目里只要沾上定时任务,很多老系统都会遇到 Quartz。它比单纯 ScheduledExecutorService 强不少,至少能持久化、能集群、能做 Cron。但它也不省心,尤其 JDBC JobStore 一配,建表、数据源、锁配置,经常能把人折腾到怀疑人生。

常见的 Job 大概长这样:

public class CleanupJob implements Job {
    public void execute(JobExecutionContext context) {
        // do cleanup
    }
}

Cron 表达式也很容易写错。Quartz 用的是 6 或 7 位 cron,多一个秒字段,别拿 Linux crontab 那套直接抄。还有一点,Quartz 触发器如果配置成错过后不补,那服务挂了一会儿,任务可能真的就没了。后来我更喜欢把“业务幂等”做扎实,别指望调度器替你兜底。

5. XXL-JOB:中心调度器确实省了运维

如果已经有多个 Java 服务,多个机器,多个任务,我一般会更倾向 XXL-JOB 这类中心调度器。原因不是它多高级,而是它把“任务列表、执行日志、失败报警、路由策略、手动执行”这些零碎事情都包了。

执行器接入以后,调度中心里能直接看到今天跑了几次,哪次失败,失败在哪台机器。工程里最怕的不是调度功能少,而是出问题时只能 SSH 上去 grep 日志。XXL-JOB 的坑也有,比如 executor appname 配错,任务根本找不到执行器;再比如网络隔离时,调度中心和 executor 回调通不通,一开始很容易忽略。

6. ElasticJob:分片调度还是得看它

有些任务不是“一个机器跑一次”就行,而是“数据分片后,每个实例跑一部分”。比如订单清理,按用户 ID 取模,分成 3 片,3 个实例各跑一片。这种场景我遇到过,最后选 ElasticJob 会比较顺。

任务里能拿到 jobParametershardingItem,写起来大概像:

if (userId % shardingTotalCount == shardingItem) {
    cleanup(userId);
}

ElasticJob 依赖 Zookeeper 或者后来的一些注册中心,别觉得“只是定时任务”就忽略协调组件。协调一断,任务可能重复跑,也可能一片没人跑。这里最稳的永远是业务侧幂等,调度器只是尽量让事情看起来正常。

7. Airflow:任务不是“到点执行”这么简单

当任务开始有依赖,比如 A 成功后才能 B,B 完成后触发 C,C 还要等待上游分区就绪,这时候再叫“定时任务”就不太合适了,这是工作流。Airflow 的 DAG 就是拿来描述这类关系的。

一个 DAG 大概长这样:

with DAG("daily_report") as dag:
    download = BashOperator(task_id="download", bash_command="python download.py")
    build = PythonOperator(task_id="build", python_callable=build_report)
    download >> build

本地别裸机折腾 Airflow 环境了,依赖太多,Python 版本、provider、database 一乱,你会开始怀疑人生。用 docker compose 起一套 webserver/scheduler/worker,至少能快速验证。Airflow 真正的坑不是调度表达式,而是 catchup、backfill、sensor timeout 这些东西,稍不注意就把 worker 队列塞满了。

8. Kubernetes CronJob:容器化之后,cron 换了个壳

服务已经都跑在 K8s 里了,那再在某个 Pod 里装 crond 就很别扭。直接用 CronJob 更干净:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: cleanup
spec:
  schedule: "30 3 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: cleanup
            image: registry.example.com/app:latest
            command: ["python", "/srv/app/cleanup.py"]
          restartPolicy: OnFailure

concurrencyPolicy: Forbid 很关键,不然上一轮任务没跑完,下一轮又启动了,两个清理任务同时动同一批数据,画面太美。还有 successfulJobsHistoryLimitfailedJobsHistoryLimit,默认会留一点 Pod 和日志,别把 etcd 当日志中心使。K8s CronJob 也有个现实问题:镜像里得真的能跑这个命令,别在开发机 Python 3.11 跑得好好的,容器里少个依赖就炸。

9. 云函数定时触发:小工具最舒服的方式

如果只是每天拉个报表、发个通知、清个队列,我会优先想到云函数加定时触发器。本地脚本部署还得管机器、日志、权限、告警,云函数反而省心很多。

比如 AWS EventBridge 这种,规则配置里写类似:

cron(30 3 * ? *)

然后目标指向 Lambda 或者另一个云函数。这里要注意时区,很多云平台的定时触发默认是 UTC,你以为配的是 3 点 30,实际触发的可能是你睡觉的时候。另一个坑是执行超时和重试,函数没配置好,任务要么一直卡,要么失败后被重复触发,最后变成“定时清理”顺手把数据清了多遍。

10. Temporal:把调度和状态机一起管

Temporal 不是传统意义上的定时任务,但它能解决更麻烦的事:任务可能很久、有状态、要重试、要暂停、要取消、要补数。比如一个对账任务,今天失败了,过一会儿重试,上游数据到了再恢复,中间还要能查状态。用普通 cron 或简单平台做这种事儿,迟早会把自己写成一个小型工作流引擎。

Temporal 里可以用 Schedule 触发 Workflow:

await client.schedule.create({
  action: {
    type: "startWorkflow",
    workflowType: "dailyReport",
    args: [],
  },
  schedule: {
    intervals: [{ every: "1 day" }],
  },
});

工程里我最怕那种“看起来只是定时任务,实际上包含大量状态”的场景。Temporal 的重试、Signal、Heartbeat 都很有用,但它也不是银弹,部署、依赖、版本兼容性一上来,运维复杂度会明显变高。能用小方案的时候别硬上,真复杂了,它确实能把一些混乱收住。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放