定时任务与调度方案
采集脚本跑通之后,「怎么让它每天自己跑」就成了新问题:用 crontab 写一行最省事,但环境变量、时区和「上次还没跑完又启动了」会把人坑一遍。本章对比五种调度方案,给出 APScheduler 的推荐写法、任务幂等的实现方式,以及错峰与限速的实践参数。
五种调度方案
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| crontab | 系统自带、零依赖 | 无重试、无并发控制、环境变量少 | 单机简单脚本 |
| systemd timer | 有日志、依赖管理、可设资源限制 | 需要写 unit 文件 | 服务器上的长期任务 |
| APScheduler | Python 内调度、支持 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 小时 | 容器默认 UTC | 设 TZ=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=1、coalesce 与 misfire_grace_time;任务必须幂等,并用 Redis 锁或 flock 保证不重叠;用错峰、抖动与限速把对目标站的影响降到最低;用 crontab 时记住绝对路径、% 转义与输出重定向。