本节目标:把脚本挂到系统的调度器上按点触发,读懂 cron 表达式,会写 crontab 与 systemd timer 单元,并用文件锁防重入、用状态文件做幂等、把日志可靠落盘。
适用版本:Python 3.12+(实测 3.14.6);仅用标准库(fcntl、logging、subprocess)
13.3 定时任务与系统集成
前两节我们把「文件」和「文档」都自动化了,但脚本还得有人在对的时间按下回车。真到生产里,这个「人」是系统的调度器:Linux 上的 cron 与 systemd timer,macOS 上的 cron 与 launchd。这一节的难点不在「怎么写一行 crontab」,而在被调度器反复触发时,脚本怎么保持正确——不重入、可幂等、日志可追。
13.3.1 cron 表达式:五个字段
cron 表达式是五个用空格分隔的字段,从「分」到「周」:
┌───────── 分钟 (0-59)
│ ┌─────── 小时 (0-23)
│ │ ┌───── 日 (1-31)
│ │ │ ┌─── 月 (1-12)
│ │ │ │ ┌─ 星期 (0-7,0 和 7 都表示周日)
│ │ │ │ │
* * * * * 要执行的命令
常见写法:
| 表达式 | 含义 | 备注 |
|---|---|---|
0 3 * * * | 每天 03:00 | 最常用的「日报」时刻 |
*/15 * * * * | 每 15 分钟 | */n 是步进 |
0 */2 * * * | 每 2 小时整点 | |
30 6 * * 1-5 | 工作日 06:30 | 1-5 是范围(周一至周五) |
0 0 1 * * | 每月 1 号 0 点 | |
0 9,18 * * * | 每天 9 点、18 点 | 逗号是枚举 |
两个反直觉的坑:
- 「日」和「星期」是「或」关系,不是「与」。
0 0 13 * 5表示「每月 13 号或每周五」,而不是「13 号且是周五」。想要「且」,得在命令里自己再判一次。 - cron 的环境极简:
PATH只有/usr/bin:/bin之类,~/.zshrc里的环境变量、pyenv、虚拟环境的activate通通不生效。务必在 crontab 里写绝对路径,或在命令里显式 source 环境。
13.3.2 把脚本写成「可调度」的样子
一个能被调度器安全反复调用的脚本,要满足:入口明确、退出码有意义、日志落盘、默认幂等。crontab 用 crontab -e 编辑、crontab -l 查看,内容示例如下:
# crontab 内容示例(cron 环境极简,路径全写绝对)
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
# 每天 03:00 跑日报;日志追加到文件,错误也进同一个文件
0 3 * * * /Users/me/jobs/venv/bin/python /Users/me/jobs/daily.py >> /Users/me/logs/daily.log 2>&1
>> ... 2>&1 是 cron 的经典写法:把标准输出和标准错误都重定向到日志文件。少了它,脚本报错时错误信息会以邮件形式堆积在系统里(或者干脆消失),你什么都看不到。
脚本自身也要把日志管起来——用标准库 logging 落盘,并配轮转,避免日志无限增长:
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
D = Path("/tmp/python_book/scratch/13")
log = logging.getLogger("job")
log.setLevel(logging.INFO)
fh = RotatingFileHandler(D / "job.log", maxBytes=10_000, backupCount=3, encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(fh)
log.addHandler(logging.StreamHandler()) # 同时打到 stderr,方便 cron 邮件
真实输出(两轮处理后的日志尾部):
2026-10-09 12:01:49,376 INFO 跳过(已处理): B
2026-10-09 12:01:49,376 INFO 跳过(已处理): C
2026-10-09 12:01:49,376 INFO 处理: D
RotatingFileHandler 在单文件超过 maxBytes 时轮转,最多保留 backupCount 个历史文件(job.log.1、job.log.2…)。单进程写日志用它没问题;多进程/多机共写同一文件则会互相截断——那种场景要用 SysLogHandler 或集中式日志。
13.3.3 幂等:重跑不重复干活
调度器可能因为补跑、手动触发、失败重试而重复执行同一批任务。幂等的定义是:跑一次和跑 N 次结果一样。最朴素可靠的实现是「状态文件记录已完成项」:
import json
from pathlib import Path
STATE = Path("/tmp/python_book/scratch/13/state.json")
def load_state() -> dict:
return json.loads(STATE.read_text()) if STATE.exists() else {"done": []}
def process(items: list[str]) -> None:
state = load_state()
done = set(state["done"])
for it in items:
if it in done:
log.info("跳过(已处理): %s", it) # 已处理,直接跳过
continue
log.info("处理: %s", it) # 真正干活
done.add(it)
state["done"] = sorted(done)
STATE.write_text(json.dumps(state, ensure_ascii=False))
真实输出(第一次处理 A/B/C,第二次处理 B/C/D):
INFO 处理: A
INFO 处理: B
INFO 处理: C
INFO 跳过(已处理): B
INFO 跳过(已处理): C
INFO 处理: D
state.json 内容:
{"done": ["A", "B", "C", "D"]}
B、C 第二次被识别为「已处理」而跳过,只有 D 是新活。三个工程要点:
- 状态写入要「原子」:直接
write_text覆盖,若写到一半进程被杀,会留下半个 JSON。稳妥做法是写临时文件再os.replace(同盘 rename 原子),保证状态文件要么是旧的、要么是新的,不会损坏。 - 状态文件会越来越大:
done列表无限增长,几年后可能几百 MB。定期清理已完成项(比如只留最近 90 天),或改用「按任务日期分目录的完成标记文件」。 - 幂等的粒度要对:以「业务键」(订单号、文件哈希)而非「行号」为去重键。行号会随数据变化而漂移,业务键才稳定。
13.3.4 任务锁:防止上一次还没跑完
如果任务耗时可能超过调度间隔(比如每 5 分钟跑一次、但一次要跑 8 分钟),必须防止重入——否则第 2 次启动时第 1 次还没结束,两个进程同时写同一份数据。
标准做法是文件锁。fcntl.flock 提供排他锁,且进程退出(含崩溃)时内核自动释放,不会像「pid 文件」那样留下死锁:
import fcntl, os, time
from pathlib import Path
LOCK = Path("/tmp/python_book/scratch/13/job.lock")
def run_with_flock() -> int:
with LOCK.open("w") as f:
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) # 非阻塞拿锁
except OSError:
print("另一个实例正在运行,本次跳过") # 拿不到就退出
return 1
print("获得锁,开始干活")
f.write(str(os.getpid()))
f.flush()
time.sleep(0.2)
print("干完,释放锁")
return 0
真实输出(单进程):
获得锁,开始干活
干完,释放锁
跨进程竞争(一个后台进程持锁 2 秒,另一个进程尝试拿锁):
child holding
跳过:另一个实例正在运行(未获得锁)
LOCK_NB(non-blocking)是关键:拿不到锁立刻返回而不是傻等——对定时任务来说,「跳过本次」通常比「排队等待」更合适。
另一种常见原语是原子创建 pid 文件,用 os.open 的 O_CREAT | O_EXCL 标志:
def acquire_pid_lock(path: Path) -> bool:
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
return False
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
真实输出:
第一次: True
第二次: False
O_EXCL 保证「检查存在 + 创建」是一个原子操作,没有竞态。但它的弱点是:进程崩溃后锁文件不会自动消失,下次启动会被误判为「正在运行」。所以 pid 锁通常要配合「读 pid 判进程是否还活着」的清理逻辑,或者干脆用 flock(更省心)。选择建议:
| 方案 | 崩溃自动释放 | 跨机器 | 适用 |
|---|---|---|---|
fcntl.flock | 是 | 否(单机) | 单机定时任务首选 |
O_CREAT|O_EXCL pid 文件 | 否(需手动清理) | 否 | 需要「人为覆盖」语义时 |
| Redis 分布式锁 | — | 是 | 多机/多实例 |
13.3.5 systemd timer:Linux 的现代调度(本机未实测)
cron 古老但通用;Linux 上更现代的选择是 systemd timer:它和 service 配套,支持依赖、日志进 journald、开机错过还能补跑(Persistent=true)。配置分两个文件。
服务单元(/etc/systemd/system/daily.service):
[Unit]
Description=Daily report job
After=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/jobs
ExecStart=/opt/jobs/venv/bin/python /opt/jobs/daily.py
# 失败重试;日志进 journald(用 journalctl -u daily 查看)
Restart=on-failure
RestartSec=30
定时器单元(/etc/systemd/system/daily.timer):
[Unit]
Description=Run daily.service every day at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
启用与查看:
sudo systemctl daemon-reload
sudo systemctl enable --now daily.timer
systemctl list-timers --all # 查看下次触发时间
journalctl -u daily.service # 查看运行日志
OnCalendar 的语法比 cron 直观:*-*-* 03:00:00 就是「每天 03:00」,还支持 Mon..Fri、hourly、daily 等简写。Persistent=true 解决 cron 的一个老大难——机器关机期间错过的任务,开机后会补跑一次。
本机未实测:当前环境是 macOS 26.3,
systemctl不存在(which systemctl返回空)。以上单元文件是 Linux 上的标准配置,未在本机运行过。macOS 对应的是launchd,见下一小节。
13.3.6 macOS 用 launchd:plist 配置
macOS 用 launchd 管理定时任务,配置是 XML 格式的 .plist。下面这份用本机 plutil -lint 实测校验通过:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.daily</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/me/jobs/daily.py</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>6</integer>
<key>Minute</key><integer>30</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/me/logs/daily.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/me/logs/daily.err.log</string>
</dict>
</plist>
校验与加载:
plutil -lint com.example.daily.plist # 校验语法
cp com.example.daily.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.example.daily.plist
launchctl list | grep com.example # 查看是否已加载
真实校验输出:
com.example.daily.plist: OK
StartCalendarInterval 对应 cron 的「定时」语义;StandardOutPath / StandardErrorPath 是 launchd 帮你做的重定向,省去 cron 里手写 >> ... 2>&1。注意 macOS 仍保留 crontab(本机 /usr/bin/crontab 存在),但 Apple 已不推荐,新项目用 launchd。
13.3.7 集成检查清单
把脚本真正挂上生产前,逐条过:
| 检查项 | 做法 |
|---|---|
| 绝对路径 | 命令、解释器、脚本、日志全写绝对路径 |
| 环境隔离 | 用 venv 里的 python,或在命令里显式设 PATH |
| 防重入 | flock 拿不到锁就退出,不排队 |
| 幂等 | 以业务键去重,状态文件原子写入 |
| 日志 | RotatingFileHandler 落盘 + cron 重定向兜底 |
| 退出码 | 成功 0、失败非 0,让调度器/监控能判定 |
| 失败可见 | 接告警(邮件、webhook),别让失败静默 |
最后一行尤其重要:定时任务最危险的形态是「静默失败」——它每天照跑,只是每天都在报错,没人看日志。至少要让失败退出码被监控捕获,或把 CRITICAL 级日志推到告警渠道。
延伸阅读
- Python 调试与日志 —— 日志分级、结构化日志与排查方法论
- 定时任务、幂等与死信处理 —— 应用内任务队列视角下的幂等与重试
小结
- cron 是五字段表达式(分 时 日 月 周);「日」与「周」是或关系,且 cron 环境极简,路径必须写绝对。
- 可调度脚本要日志落盘(
RotatingFileHandler)+ cron 重定向>> ... 2>&1兜底,并给出有意义的退出码。 - 幂等靠业务键 + 状态文件;状态写入用「临时文件 +
os.replace」保证原子性。 - 防重入首选
fcntl.flock(进程退出自动释放),LOCK_NB拿不到就跳过;pid 文件方案崩溃后需手动清理。 - systemd timer 是 Linux 现代方案,
OnCalendar直观、Persistent=true能补跑关机期错过的任务——本机 macOS 未实测。 - macOS 用 launchd 的
.plist,本机plutil -lint校验通过;crontab仍可用但不推荐。
到这里,「数据与自动化」这一部分收尾:我们从 pandas 数据处理走到文件、文档、定时调度。下一章换一种交付形态——把脚本包装成别人也能用的命令行工具,从 Click / Typer 的接口设计开始。
阅读导航:上一节:Excel / Word / PDF 自动化 · 下一节:Click / Typer 构建 CLI 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。