定时任务与调度方案

采集脚本跑通之后,「怎么让它每天自己跑」就成了新问题:用 crontab 写一行最省事,但环境变量、时区和「上次还没跑完又启动了」会把人坑一遍。本章对比五种调度方案,给出 APScheduler 的推荐写法、任务幂等的实现方式,以及错峰与限速的实践参数。

五种调度方案

方案优点缺点适用场景
crontab系统自带、零依赖无重试、无并发控制、环境变量少单机简单脚本
systemd timer有日志、依赖管理、可设资源限制需要写 unit 文件服务器上的长期任务
APSchedulerPython 内调度、支持 cron/interval、可持久化需自己处理进程守护与锁单机多任务、需要错峰
Celery beat分布式、任务可分散到多 worker组件多(broker + worker)已有 Celery 技术栈
K8s CronJob与容器编排一体、失败自动重试、资源隔离需要集群容器化部署的团队

选择顺序:单机小规模用 APScheduler,需要开机自启与日志就用 systemd timer 包一层;任务量大、要分散执行再上 Celery beat 或 K8s CronJob。crontab 不是不能用,而是缺少重试与并发控制,采集任务很容易踩「上一次还在跑」的坑。

APScheduler 推荐写法

# schedule_jobs.py —— Python 3.10+,依赖 apscheduler 3.x
import logging
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("crawler.schedule")

def crawl_news() -> None:
    logger.info("开始采集资讯任务")          # 这里调用爬虫或提交任务,不写业务逻辑

def crawl_price() -> None:
    logger.info("开始采集价格任务")

def build_scheduler() -> BlockingScheduler:
    sched = BlockingScheduler(
        timezone="Asia/Shanghai",                       # 显式指定时区,避免容器 UTC 差 8 小时
        executors={"default": ThreadPoolExecutor(4)},    # 线程池并发,任务之间不互相阻塞
        job_defaults={
            "max_instances": 1,        # 关键:上一次未结束时不再叠加实例
            "coalesce": True,          # 堆积的多次触发合并成一次
            "misfire_grace_time": 600, # 迟到 10 分钟内仍执行,超过则跳过
        },
    )
    # 资讯类:每 2 小时一次,避开整点高峰
    sched.add_job(crawl_news, CronTrigger(hour="*/2", minute=17), id="news", replace_existing=True)
    # 价格类:每天 09:05 与 21:05,避开目标站流量高峰
    sched.add_job(crawl_price, CronTrigger(hour="9,21", minute=5), id="price", replace_existing=True)
    return sched

if __name__ == "__main__":
    scheduler = build_scheduler()
    logger.info("调度器启动:%s", [job.id for job in scheduler.get_jobs()])
    scheduler.start()      # 阻塞主线程;Web 项目里改用 BackgroundScheduler

三个参数必须先搞清再上线:max_instances=1 防止上一轮没跑完又启动;coalesce=True 把停机期间堆积的触发压成一次,避免恢复瞬间爆发;misfire_grace_time 决定「迟到多久还算数」,太大则重启后集中补跑,太小则静默跳过。

幂等与「上次没跑完」

调度层再可靠也不能保证任务不重叠,所以任务本身必须幂等:重复执行不产生重复数据。同时要有一道「同一任务同时只跑一个」的锁。

# job_lock.py
import contextlib, uuid, redis

@contextlib.contextmanager
def redis_lock(client: redis.Redis, key: str, ttl: int = 3600):
    """基于 SET NX EX 的分布式锁,进程崩溃后由 TTL 自动释放"""
    token = uuid.uuid4().hex
    if not client.set(key, token, nx=True, ex=ttl):
        yield False
        return
    try:
        yield True
    finally:
        if client.get(key) == token.encode():   # 只删自己持有的锁
            client.delete(key)

client = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
with redis_lock(client, "lock:crawl:news", ttl=1800) as acquired:
    if not acquired:
        print("上一轮仍在运行或未超时,本次跳过")

纯单机脚本用 flock 更省事,无依赖也不会因 Redis 抖动失效:

#!/usr/bin/env bash
set -euo pipefail
exec 9>/var/lock/crawl-daily.lock
if ! flock -n 9; then
  echo "已有实例在运行,退出"      # -n 非阻塞:拿不到锁立刻退出
  exit 0
fi
cd /opt/crawler
/usr/bin/python3 -m crawler.run --site news >> /var/log/crawler/news.log 2>&1

错峰与限速

手段做法效果
避开整点minute=17 而不是 minute=0避开与其他系统同点的资源争抢
避开目标站高峰选凌晨或夜间低谷时段降低被限流概率,减少对站点干扰
多任务错开起始分钟分别用 :05:17:31避免自己把自己挤成并发高峰
任务内部限速DOWNLOAD_DELAY + CONCURRENT_REQUESTS_PER_DOMAIN压力控制在站点可承受范围
分批执行大批量任务拆成多个小批次单批次失败影响面小
随机抖动起始时间加 0~120 秒随机避免长期固定节律被识别

crontab 的常见坑

现象原因处理
手动能跑,cron 不执行cron 的 PATH 极短,找不到 python3写绝对路径 /usr/bin/python3
环境变量丢失cron 不加载 shell 配置脚本里显式 export,或写进 /etc/environment
命令被截断报语法错误% 在 crontab 里表示换行必须转义为 \%
没有输出、无从排查标准输出被丢弃>> /var/log/xxx.log 2>&1
时区差 8 小时容器默认 UTCTZ=Asia/Shanghai 或改用 APScheduler 指定时区
任务重叠执行cron 不做并发控制脚本内用 flock,或 max_instances=1
# 每 2 小时的第 17 分钟跑一次;注意绝对路径与输出重定向
17 */2 * * * cd /opt/crawler && /usr/bin/python3 -m crawler.run --site news >> /var/log/crawler/news.log 2>&1
crontab -l                              # 查看已安装任务
grep CRON /var/log/syslog | tail -20    # 查看上次执行记录

合规提示:定时采集意味着「长期、规律地访问对方站点」,比一次性抓取更要注意分寸。请遵守 robots.txt 与站点服务条款,把频率与并发控制在站点可承受范围内并优先使用官方 API 或开放数据;只采集公开数据,不采集个人隐私信息,不绕过登录、验证码与付费墙。数据使用符合《个人信息保护法》《数据安全法》《著作权法》要求。

小结:调度方案按「是否需要分布式」来选择,单机首选 APScheduler 并显式设置时区、max_instances=1coalescemisfire_grace_time;任务必须幂等,并用 Redis 锁或 flock 保证不重叠;用错峰、抖动与限速把对目标站的影响降到最低;用 crontab 时记住绝对路径、% 转义与输出重定向。

笔记加载中…